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

@lastshotlabs/snapshot

v0.1.4

Published

React frontend framework for bunshot-powered backends

Readme

@lastshotlabs/snapshot

React frontend framework for bunshot-powered backends.

Provides auth, API client, WebSocket, routing guards, and theme — all wired via a single factory call.


Installation

bun add @lastshotlabs/snapshot

Peer dependencies (install separately):

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.

bunx @lastshotlabs/snapshot init "My App"

With a custom output directory:

bunx @lastshotlabs/snapshot init "My App" my-app-dir

Skip all prompts and accept defaults:

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

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.

// 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

// 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

// 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.

// 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:

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 for details.


Configuration

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"),
  onLogoutSuccess: () => console.log("logged out"),

  // Token storage
  tokenStorage: "sessionStorage", // 'sessionStorage' | 'memory' | 'localStorage' — default: 'sessionStorage' (token mode only)
  tokenKey: "x-user-token", // default: 'x-user-token'

  // Auth error formatting — controls how auth error messages are displayed to users
  authErrors: {
    verbose: false, // default: true on localhost, false elsewhere
    messages: { login: "Incorrect credentials." },
    format: (error, context) => `[${context}] ${error.message}`,
  },

  // Auth contract — remap endpoint paths, header names, or CSRF cookie name
  contract: {
    endpoints: { login: "/v2/auth/login" },
    headers: { userToken: "x-session-token", csrf: "x-xsrf-token" },
    csrfCookieName: "XSRF-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/chat",

    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

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

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

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.

Note: Failed logouts do not clear auth state. If the server call fails, the user remains authenticated. Only a successful server response or force: true triggers cleanup.

Force-logout — bypass the server call and immediately clear local state (useful when the server is unreachable or you've already invalidated the session externally):

const logout = useLogout();
logout.mutate({ force: true });

force: true resolves immediately without hitting /auth/logout. All local state (tokens, cache, MFA challenge) is still cleared.

The onLogoutSuccess callback fires after a successful logout (or after force: true). It is distinct from onUnauthenticated, which fires on session expiry:

| Callback | When it fires | | ------------------- | ----------------------------------------------------------------- | | onUnauthenticated | Session expiry, 401 responses, or redirect from a protected route | | onLogoutSuccess | After a successful logout (server confirmed or force: true) |

Register

import { useRegister } from "@lib/snapshot";

const register = useRegister();
register.mutate({ email, password });

Forgot Password

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.

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.

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:

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:

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

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

import { useMfaDisable } from "@lib/snapshot";

const disable = useMfaDisable();
disable.mutate({ code: "123456" }); // requires current TOTP code

Recovery codes

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

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

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.

createSnapshot({
  apiUrl: import.meta.env.VITE_API_URL,
  mfaSetupPath: "/mfa-setup", // auto-redirect on MFA_SETUP_REQUIRED
});

Account Management

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:

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:

// 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:

// @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:

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.

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.

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

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.

usePasskeyLogin accepts PasskeyLoginVars — a PasskeyLoginBody extended with an optional redirectTo override:

passkeyLogin.mutate({
  passkeyToken,
  assertionResponse,
  redirectTo: "/dashboard",
});

Auth Error Formatting

By default, scaffold templates display raw server error messages, which can leak account existence information (e.g. "email not found" confirms an email is registered). The formatAuthError utility returns safe, context-appropriate messages instead.

Standalone usage

import { formatAuthError } from "@lastshotlabs/snapshot";

// In a component:
{
  login.isError && <p>{formatAuthError(login.error, "login")}</p>;
}

Safe defaults per context (on non-localhost):

| Context | Default message | | ----------------- | -------------------------------------------------------------------------- | | login | Invalid email or password. | | register | Unable to create account. Please try again. | | forgot-password | If that email is registered, you'll receive a password reset link shortly. | | reset-password | Unable to reset password. The link may have expired. | | verify-email | Unable to verify email. The link may have expired or already been used. |

On localhost, verbose mode is enabled automatically — raw server messages are shown to aid development.

Configuration

createSnapshot({
  apiUrl: "...",
  authErrors: {
    // Force verbose mode (shows raw server messages). Default: true on localhost, false elsewhere.
    verbose: false,

    // Override individual messages:
    messages: {
      login: "Incorrect credentials.",
    },

    // Or provide a fully custom formatter:
    format: (error, context) => `[${context}] ${error.message}`,
  },
});

The instance also exposes a pre-bound formatter that picks up your authErrors config automatically:

const { formatAuthError } = snapshot;

// equivalent to formatAuthError(error, context, config.authErrors)

Factory usage

For cases where you want a reusable formatter outside of a snapshot instance:

import { createAuthErrorFormatter } from "@lastshotlabs/snapshot";

const fmt = createAuthErrorFormatter({ verbose: false });
fmt(error, "login"); // → 'Invalid email or password.'

Configurable Auth Contract

By default, snapshot uses Bunshot's standard endpoint paths (e.g. /auth/login, /auth/me) and header names (x-user-token, x-csrf-token). If your backend uses different paths — due to API versioning, a gateway prefix, or a custom auth server — you can remap any of them without forking the library.

Remapping endpoints

createSnapshot({
  apiUrl: "https://api.example.com",
  contract: {
    endpoints: {
      login: "/v2/auth/login",
      me: "/v2/users/me",
    },
    // All other endpoints remain at their defaults
  },
});

Remapping headers and cookies

createSnapshot({
  apiUrl: "https://api.example.com",
  contract: {
    headers: {
      userToken: "x-session-token", // default: 'x-user-token'
      csrf: "x-xsrf-token", // default: 'x-csrf-token'
    },
    csrfCookieName: "XSRF-TOKEN", // default: 'csrf_token'
  },
});

Dynamic path overrides

For dynamic paths (session revocation, WebAuthn credential removal, OAuth URLs), provide a function:

createSnapshot({
  apiUrl: "https://api.example.com",
  contract: {
    sessionRevoke: (id) => `/v2/sessions/${id}`,
    oauthUrl: (provider) => `https://auth.example.com/oauth/${provider}`,
  },
});

All configurable fields

| Field | Type | Default | | ----------------------------------- | ------------------------------------- | -------------------------------------------- | | endpoints.me | string | /auth/me | | endpoints.login | string | /auth/login | | endpoints.logout | string | /auth/logout | | endpoints.register | string | /auth/register | | endpoints.forgotPassword | string | /auth/forgot-password | | endpoints.refresh | string | /auth/refresh | | endpoints.resetPassword | string | /auth/reset-password | | endpoints.verifyEmail | string | /auth/verify-email | | endpoints.resendVerification | string | /auth/resend-verification | | endpoints.setPassword | string | /auth/set-password | | endpoints.deleteAccount | string | /auth/me | | endpoints.cancelDeletion | string | /auth/cancel-deletion | | endpoints.sessions | string | /auth/sessions | | endpoints.mfaVerify | string | /auth/mfa/verify | | endpoints.mfaSetup | string | /auth/mfa/setup | | endpoints.mfaVerifySetup | string | /auth/mfa/verify-setup | | endpoints.mfaDisable | string | /auth/mfa | | endpoints.mfaRecoveryCodes | string | /auth/mfa/recovery-codes | | endpoints.mfaEmailOtpEnable | string | /auth/mfa/email-otp/enable | | endpoints.mfaEmailOtpVerifySetup | string | /auth/mfa/email-otp/verify-setup | | endpoints.mfaEmailOtpDisable | string | /auth/mfa/email-otp | | endpoints.mfaResend | string | /auth/mfa/resend | | endpoints.mfaMethods | string | /auth/mfa/methods | | endpoints.webauthnRegisterOptions | string | /auth/mfa/webauthn/register-options | | endpoints.webauthnRegister | string | /auth/mfa/webauthn/register | | endpoints.webauthnCredentials | string | /auth/mfa/webauthn/credentials | | endpoints.webauthnDisable | string | /auth/mfa/webauthn | | endpoints.passkeyLoginOptions | string | /auth/passkey/login-options | | endpoints.passkeyLogin | string | /auth/passkey/login | | endpoints.oauthExchange | string | /auth/oauth/exchange | | sessionRevoke | (id: string) => string | `/auth/sessions/${id}` | | webauthnRemoveCredential | (id: string) => string | `/auth/mfa/webauthn/credentials/${id}` | | oauthUrl | (provider: OAuthProvider) => string | `${apiUrl}/auth/${provider}` | | oauthLinkUrl | (provider: OAuthProvider) => string | `${apiUrl}/auth/${provider}/link` | | oauthUnlink | (provider: OAuthProvider) => string | `/auth/${provider}/link` | | headers.userToken | string | x-user-token | | headers.csrf | string | x-csrf-token | | csrfCookieName | string | csrf_token |

Building on the default contract

If you want to start from the defaults and only patch a few values:

import { defaultContract, mergeContract } from "@lastshotlabs/snapshot";

const myContract = mergeContract("https://api.example.com", {
  endpoints: { login: "/v2/login" },
});

Route Guards

Assign protectedBeforeLoad and guestBeforeLoad in your route files:

// 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,
});
// 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):

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:

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:

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

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

// 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

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:

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).


Server-Sent Events (SSE)

Breaking change from older versions: The single-URL sse.url config and useSseManager() hook have been replaced with a per-endpoint model. If you were using the old API, see the migration notes below.

When to use SSE vs WebSocket

Use SSE when the server needs to push events to the browser but the browser never sends data back — activity feeds, notification streams, live counters. The browser connects once and the server writes events forever. SSE is unidirectional (server → client only).

Use WebSocket when you need bidirectional communication — chat, presence, collaborative editing, anything where the client also sends messages to the server.

Config

SSE config is a map of endpoint paths. Each key must start with /__sse/ and must match a key in your bunshot sse.endpoints config. One EventSource is created per endpoint at startup.

// src/lib/snapshot.ts
export const snapshot = createSnapshot({
  apiUrl: "https://api.example.com",
  sse: {
    endpoints: {
      "/__sse/feed": {
        withCredentials: false, // default false; set true for cross-origin
        onConnected: () => console.log("feed connected"),
        onError: (e) => console.warn("feed error", e),
        onClosed: () => console.log("feed closed"),
      },
      "/__sse/notifications": {}, // empty object = default options
    },
    reconnectOnLogin: true, // default true — reconnect all endpoints after login
  },
});

SseEndpointConfig fields are all optional. An empty object {} is valid.

Connection status

useSSE(endpoint) returns { status } for the given endpoint:

import { snapshot } from "@lib/snapshot";

const { useSSE } = snapshot;

function FeedStatus() {
  const { status } = useSSE("/__sse/feed");
  // status: 'connecting' | 'open' | 'closed'
  return <span>{status === "open" ? "Live" : "Connecting..."}</span>;
}

Receiving events

useSseEvent<T>(endpoint, event) subscribes to a named event on a specific endpoint. Returns { data: T | null; status }.

import { snapshot } from "@lib/snapshot";

const { useSseEvent } = snapshot;

function ThreadFeed() {
  const { data: newThread, status } = useSseEvent<{
    id: string;
    title: string;
  }>("/__sse/feed", "community:thread.created");

  useEffect(() => {
    if (newThread) {
      // handle new thread arrival
    }
  }, [newThread]);

  return <span>{status}</span>;
}

data is the latest received payload. It starts as null and updates each time the event fires. The subscription is set up on mount and cleaned up on unmount.

SSE auth

Same-origin: The browser sends cookies automatically on the SSE request — no extra config needed.

Cross-origin: Set withCredentials: true on the endpoint config. The server must respond with Access-Control-Allow-Origin: <your-origin> (not *) and Access-Control-Allow-Credentials: true.

Important: Browsers do not allow custom request headers on SSE connections. You cannot send Authorization: Bearer <token> with a native EventSource. If you need token auth for SSE, use cookie-based auth (the default snapshot mode) or proxy the SSE endpoint through your own server.

After login, snapshot automatically reconnects all SSE endpoints so new connections carry the authenticated cookie (when reconnectOnLogin: true, which is the default). On logout, all SSE connections are closed.

Migration from the old single-URL API

| Old | New | | ---------------------------------------- | -------------------------------------------------------------- | | sse: { url: '/__sse/feed', ... } | sse: { endpoints: { '/__sse/feed': { ... } } } | | useSseManager() | useSSE(endpoint) — returns { status } | | useSseEvent(event, handler) (callback) | useSseEvent<T>(endpoint, event) — returns { data, status } | | TSseEvents generic on createSnapshot | Removed — type each useSseEvent<T> call site directly |


Community

The community module provides hooks for the full bunshot-community API surface — containers, threads, replies, reactions, members, moderation, notifications, and search.

Factory

import { createCommunityHooks } from "@lastshotlabs/snapshot";
import { api, queryClient } from "@lib/snapshot";

export const community = createCommunityHooks({ api, queryClient });

export const {
  useContainers,
  useContainer,
  useCreateContainer,
  useContainerThreads,
  useContainerThread,
  useCreateThread,
  useThreadReplies,
  useCreateReply,
  useSearchThreads,
  useSearchReplies,
  // ... all 47 hooks
} = community;

Available hooks

Containers

| Hook | Description | | --------------------------- | ------------------------------- | | useContainers(params?) | List all containers (paginated) | | useContainer(containerId) | Get a single container | | useCreateContainer() | Create a container | | useUpdateContainer() | Update a container | | useDeleteContainer() | Delete a container |

Threads

| Hook | Description | | ------------------------------------------------- | --------------------------- | | useContainerThreads({ containerId, ...params }) | List threads in a container | | useContainerThread(threadId) | Get a single thread | | useCreateThread() | Create a thread | | useUpdateThread() | Update a thread | | useDeleteThread() | Delete a thread | | usePublishThread() | Publish a draft thread | | useLockThread() | Lock a thread (mod/admin) | | usePinThread() | Pin a thread | | useUnpinThread() | Unpin a thread |

Replies

| Hook | Description | | ------------------------------------------- | ------------------------ | | useThreadReplies({ threadId, ...params }) | List replies to a thread | | useReply(replyId) | Get a single reply | | useCreateReply() | Create a reply | | useUpdateReply() | Update a reply | | useDeleteReply() | Delete a reply |

Reactions

| Hook | Description | | ------------------------------ | ------------------------------- | | useThreadReactions(threadId) | List reactions on a thread | | useAddThreadReaction() | Add a reaction to a thread | | useRemoveThreadReaction() | Remove a reaction from a thread | | useReplyReactions(replyId) | List reactions on a reply | | useAddReplyReaction() | Add a reaction to a reply | | useRemoveReplyReaction() | Remove a reaction from a reply |

Members, Moderators, Owners

| Hook | Description | | ---------------------------------------------- | --------------------------- | | useContainerMembers(containerId, params?) | List members of a container | | useContainerModerators(containerId, params?) | List moderators | | useContainerOwners(containerId, params?) | List owners | | useAddMember() | Add a member to a container | | useRemoveMember() | Remove a member | | useAssignModerator() | Assign a moderator | | useRemoveModerator() | Remove a moderator | | useAssignOwner() | Assign an owner | | useRemoveOwner() | Remove an owner |

Notifications

| Hook | Description | | ------------------------------- | --------------------------------------- | | useNotifications(params?) | List notifications for the current user | | useNotificationsUnreadCount() | Get unread notification count | | useMarkNotificationRead() | Mark a single notification read | | useMarkAllNotificationsRead() | Mark all notifications read |

Reports and Moderation

| Hook | Description | | --------------------- | ------------------------------- | | useReports(params?) | List all reports (mod/admin) | | useReport(reportId) | Get a single report | | useCreateReport() | File a report on content | | useResolveReport() | Resolve a report | | useDismissReport() | Dismiss a report without action |

Bans

| Hook | Description | | ----------------------------------- | ----------------------------------------------- | | useBans(params?) | List all bans (mod/admin) | | useCheckBan(userId, containerId?) | Check if a user is banned (scoped or site-wide) | | useCreateBan() | Ban a user | | useRemoveBan() | Remove a ban |

Search

| Hook | Description | | -------------------------- | ----------------------------------- | | useSearchThreads(params) | Search threads (requires q param) | | useSearchReplies(params) | Search replies (requires q param) |

Cache invalidation

  • Create/update/delete mutations for threads and replies sweep the ['community', 'search'] prefix — new content appears in search immediately.
  • Reaction mutations invalidate the affected thread or reply detail query.
  • Ban mutations sweep ['community', 'bans', userId, 'check'] — all check variants for that user (scoped and site-wide) are invalidated together.

Webhooks

The webhooks module provides hooks for managing outbound webhook endpoints and inspecting delivery history.

Factory

import { createWebhookHooks } from "@lastshotlabs/snapshot";
import { api, queryClient } from "@lib/snapshot";

export const webhooks = createWebhookHooks({ api, queryClient });

export const {
  useListWebhookEndpoints,
  useCreateWebhookEndpoint,
  useUpdateWebhookEndpoint,
  useTestWebhookEndpoint,
  // ...
} = webhooks;

Available hooks

Endpoints

| Hook | Description | | ----------------------------------- | ------------------------------------- | | useListWebhookEndpoints() | List all registered webhook endpoints | | useGetWebhookEndpoint(endpointId) | Get a single endpoint | | useCreateWebhookEndpoint() | Register a new endpoint | | useUpdateWebhookEndpoint() | Update an endpoint (PATCH) | | useDeleteWebhookEndpoint() | Soft-delete an endpoint |

Deliveries

| Hook | Description | | ----------------------------------------------------- | ------------------------------------- | | useListWebhookDeliveries({ endpointId, ...params }) | List delivery history for an endpoint | | useGetWebhookDelivery(deliveryId) | Get a single delivery record |

Testing

| Hook | Description | | -------------------------- | ------------------------------------------------------------------------- | | useTestWebhookEndpoint() | Fire a test delivery to an endpoint; invalidates delivery list on success |

No retry hook: Bunshot manages retries internally via BullMQ. There is no client-triggered retry endpoint.


Theme

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:

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

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

// 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

// 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