Typed client and React hooks for the Doughmination API — Discord presence, Minecraft & Hypixel stats, the plural system (fronters, members, mental state), devices and the guestbook — all backed by a single shared WebSocket for live updates.
- Zero-config reads. Every public read works with no auth. Construct the provider with nothing but a
QueryClientand you're live. - One connection.
<DoughminationProvider>owns exactly one socket for the whole tree. Presence subscriptions are ref-counted, so unmounting one component never kills another's feed. - Fully typed from the source. Response types are hand-written from the API handlers, not a spec.
- ESM + CJS + types, React 18/19, TanStack Query v5.
npm i @doughmination/react-api @tanstack/react-query react
# or: bun add @doughmination/react-api @tanstack/react-query reactreact and @tanstack/react-query are peer dependencies — the package uses your app's copies.
Wrap your app in a TanStack QueryClientProvider, then DoughminationProvider:
import{QueryClient,QueryClientProvider}from"@tanstack/react-query";import{DoughminationProvider}from"@doughmination/react-api";constqueryClient=newQueryClient();exportfunctionApp(){return(<QueryClientProviderclient={queryClient}><DoughminationProvider><Dashboard/></DoughminationProvider></QueryClientProvider>);}That's the whole setup for public reads and live updates. Auth and captcha are only needed for writes (see Authentication and Turnstile).
<DoughminationProviderbaseUrl="https://doughmination.uk/v2"// defaulttoken={()=>localStorage.getItem("token")}// JWT for writes; function or stringbatteryKey={process.env.BATTERY_KEY}// X-Battery-Key for device/guestbook adminturnstile={()=>turnstileTokenRef.current}// supplies captcha tokens (see below)realtime// default true; set false to disable the socketonError={(e)=>console.error(e)}>{children}</DoughminationProvider>token, batteryKey and botToken each accept a string or a (sync/async) function, so you can read from your own auth store on every request without rebuilding the client.
Every read hook is a thin TanStack Query wrapper — you get data, isLoading, error, refetch and can pass through any query option.
import{useDiscordUser,useDiscordUsers,useMinecraftProfile,useHypixelStats,useFronters,useMembers,useMentalState,useDevices,useGuestbook,}from"@doughmination/react-api";useDiscordUser("209830981060788225");// merged profile + badges + presenceuseGenshinRoster("691386457");// Genshin Impact rosteruseMinecraftProfile("79ef438d69ea473c99cd6a5ec34c6736");// skin, capes, render URLsuseHypixelStats(uuid);// Hypixel + SkyBlock (allowlisted UUIDs only)useFronters();// current front — live (see below)useMembers();// all members, with tags + statususeMentalState();// current mental state — liveuseDevices();// all device battery/state — liveuseGuestbook({limit: 20,offset: 0});// newest first, keeps previous page while loadingExample:
functionProfileCard({ id }: {id: string}){const{ data, isLoading, error }=useDiscordUser(id);if(isLoading)return<Spinner/>;if(error)return<p>{error.message}</p>;return(<div><imgsrc={data.user.avatar_url}alt=""/><strong>{data.user.display_name??data.user.username}</strong><span>{data.presence?.status??"offline"}</span></div>);}player and skyblock come back as raw upstream blobs (the API defines no schema for them). Typed accessors read the common fields defensively:
import{useHypixelStats,getPlayerSummary,getSkyblockProfiles}from"@doughmination/react-api";functionStats({ uuid }: {uuid: string}){const{ data }=useHypixelStats(uuid);constplayer=getPlayerSummary(data);// { rank, networkLevel, karma, firstLogin, ... }constprofiles=getSkyblockProfiles(data);return<p>{player.rank??"Unranked"} · level {Math.floor(player.networkLevel??0)}</p>;}useHypixelStats returns 403 for any UUID that isn't one of the operator's own accounts — that's by design (Hypixel's API policy forbids proxying arbitrary players). An allowlisted player who's never joined Hypixel still resolves 200; check data.source.player to tell the cases apart.
The API exposes one socket at /v2/ws. The provider opens it once and fans events out to every hook. It handles reconnect (exponential backoff + jitter), keepalive (ping→pong), and re-sends subscriptions after a reconnect.
Three event types are pushed to every client automatically — no subscription needed:
fronters_update→useFronters()stays livemental_state_update→useMentalState()stays livedevice_update→useDevices()/useDeviceState()stay live
So useFronters() seeds from REST and then updates itself on every switch, including switches made in other browsers:
functionFrontList(){const{ data }=useFronters();// updates live, no extra wiringreturn(<ul>{data?.members?.map((m)=>(<likey={m.id}>{m.display_name??m.name}{m.tags?.includes("Host")&&" · Host"}{m.status&&` — ${m.status.text}`}</li>))}</ul>);}Note: the
fronters_updateevent carries PluralKit's raw object, which is missing thetags/statusenrichment the REST route adds. This package merges live payloads over the cached data per member, so those fields survive a switch. You don't need to do anything.
Presence is the one opt-in feed. usePresence sends the subscribe frame, receives the init_state snapshot, then live presence_update events — for the users you asked for only. Subscriptions are ref-counted, so several components can watch overlapping ids safely.
import{usePresence}from"@doughmination/react-api";functionLivePresence({ ids }: {ids: string[]}){const{ presences, isLive, isReady }=usePresence(ids);// ids can be a fresh array each render — subscriptions are keyed by sorted ids.if(!isReady)return<p>{isLive ? "Loading…" : "Connecting…"}</p>;return(<ul>{ids.map((id)=>{constp=presences[id];return(<likey={id}>{id}: {p?.status??"offline"}{p?.listening_to_spotify&&` · ♫ ${p.spotify?.song}`}</li>);})}</ul>);}Pass "all" to follow every tracked user: usePresence("all"). For a single user, useUserPresence(id) returns just that UnifiedPresence | undefined. useConnectionStatus() gives the socket lifecycle (idle | connecting | open | reconnecting | closed).
import{useDeviceState}from"@doughmination/react-api";functionBattery(){const{ device, isLive }=useDeviceState("iphone");if(!device)return<span>—</span>;return(<span>{device.level}%{device.charging ? " ⚡" : ""}{device.wifi&&` · ${device.wifi}`}{!isLive&&" (stale)"}</span>);}import{useDoughminationEvent}from"@doughmination/react-api";useDoughminationEvent("force_refresh",()=>toast("Data refreshed"));By default the provider also invalidates all package queries when it receives force_refresh (toggle with invalidateOnForceRefresh).
Reads need nothing. Writes (switching fronters, setting mental state, device reports, guestbook moderation) need a credential on the provider.
import{useLogin,useSetFronters}from"@doughmination/react-api";functionLoginForm(){constlogin=useLogin();asyncfunctiononSubmit(username: string,password: string,turnstileToken: string){const{ access_token }=awaitlogin.mutateAsync({ username, password, turnstileToken });localStorage.setItem("token",access_token);// you store it; feed it back via provider `token`}}// Once the provider has the token, writes just work:functionSwitchButton({ ids }: {ids: string[]}){constsetFronters=useSetFronters();return<buttononClick={()=>setFronters.mutate(ids)}>Switch</button>;// No manual refetch — the API broadcasts fronters_update and useFronters() updates itself.}The package never stores your token — put it wherever your app keeps auth state and pass it back through the provider's token prop.
Unverified accounts. New signups must confirm their email before login. A blocked login rejects with a DoughminationError where status === 403 and code === "email_unverified" — use that to offer a "resend confirmation" action rather than "wrong password".
Signup now requires an email and returns a one-time correction_token (for fixing a typo'd address without a password). The full flow is covered:
import{useSignup,useVerifyEmail,useResendVerification,useCorrectEmail,useForgotPassword,useForgotUsername,useResetPassword,useResetTokenValid,useUsernameAvailable,useEmailAvailable,}from"@doughmination/react-api";constsignup=useSignup();const{ correction_token }=awaitsignup.mutateAsync({ username, password, email });useVerifyEmail().mutate(tokenFromUrl);// confirm the address (no captcha)useResetTokenValid(tokenFromUrl);// check a reset link before showing the formuseForgotPassword().mutate({ username });// email a reset linkuseResetPassword().mutate({ token, newPassword });// set the new passwordLogin, signup, guestbook posts and the recovery endpoints are Cloudflare Turnstile–gated. This package cannot generate a captcha token — it comes from the widget you render. Supply it one of two ways:
Per call — pass turnstileToken in the mutation variables:
login.mutate({ username, password, turnstileToken });Provider-wide — give the provider a turnstile callback that returns the current token; every gated mutation uses it as the fallback:
consttokenRef=useRef<string>("");<DoughminationProviderturnstile={()=>tokenRef.current}>{/* render Turnstile's widget somewhere and set tokenRef.current in its callback */}</DoughminationProvider>import{useGuestbook,useGuestbookPost}from"@doughmination/react-api";functionGuestbook(){const{ data }=useGuestbook({limit: 20});constpost=useGuestbookPost();// turnstile from provider, or pass turnstileToken hereasyncfunctionsign(name: string,message: string){constres=awaitpost.mutateAsync({ name, message });if(res.skipped)return;// honeypot tripped — API fakes success and drops it}return<>{data?.entries.map((e)=><pkey={e.id}><b>{e.name}</b>: {e.message}</p>)}</>;}Guestbook posts are rate limited to one per 60s per IP — that surfaces as a DoughminationError with isRateLimited === true.
Both of the API's error conventions ({success:false, error:{code,message}} from the Worker routes and {detail} from the system routes) are normalised into one DoughminationError:
import{isDoughminationError}from"@doughmination/react-api";try{awaitpost.mutateAsync({ name, message });}catch(err){if(isDoughminationError(err)){if(err.isRateLimited)show("Slow down a moment.");elseif(err.isAuthError)show("Please log in again.");elseshow(err.message);// err.status, err.code, err.body also available}}The typed client is exported on its own — handy for scripts, SSR loaders, or route handlers:
import{DoughminationClient}from"@doughmination/react-api";constclient=newDoughminationClient();// reads need no configconstfronters=awaitclient.getFronters();constrecord=awaitclient.getDiscordUser("209830981060788225");The public read routes (/discord/*, /minecraft/*) allow any origin. The system routes (/plural/*, /devices, /guestbook) use an allowlist with credentials — by default doughmination.uk, doughmination.co.uk, c.stupid.cat, and any localhost port. If you host your frontend elsewhere, add its origin to the API's CORS_ORIGINS, or those calls will fail in the browser.
| Area | Hooks |
|---|---|
| Discord | useDiscordUser, useDiscordUsers, useDiscordStatus |
| Minecraft | useMinecraftProfile, useHypixelStats, useMinecraftCapes |
| Plural | useFronters, useMembers, useMember, useMentalState, useSystem, useMemberStatus, useUserInfo |
| Devices | useDevices, useDeviceState |
| Guestbook | useGuestbook, useGuestbookPost, useDeleteGuestbookEntry |
| Presence / realtime | usePresence, useUserPresence, useConnectionStatus, useDoughminationEvent |
| Auth & writes | useLogin, useSignup, useSetFronters, useSwitchFront, useSetMentalState, useReportDevice |
| Account recovery | useVerifyEmail, useResendVerification, useCorrectEmail, useForgotPassword, useForgotUsername, useResetPassword, useResetTokenValid, useUsernameAvailable, useEmailAvailable |
Escape hatches: useDoughminationClient() (the client), useDoughminationSocket() (the raw socket), queryKeys (for manual cache work).
bun install
bun run typecheck
bun run build # tsup → dist/ (ESM + CJS + .d.ts)ESAL-2.3