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";
const queryClient = new QueryClient();
export function App() {
return (
<QueryClientProvider client={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).
<DoughminationProvider
baseUrl="https://doughmination.uk/v2" // default
token={() => localStorage.getItem("token")} // JWT for writes; function or string
batteryKey={process.env.BATTERY_KEY} // X-Battery-Key for device/guestbook admin
turnstile={() => turnstileTokenRef.current} // supplies captcha tokens (see below)
realtime // default true; set false to disable the socket
onError={(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 + presence
useGenshinRoster("691386457"); // Genshin Impact roster
useMinecraftProfile("79ef438d69ea473c99cd6a5ec34c6736"); // skin, capes, render URLs
useHypixelStats(uuid); // Hypixel + SkyBlock (allowlisted UUIDs only)
useFronters(); // current front — live (see below)
useMembers(); // all members, with tags + status
useMentalState(); // current mental state — live
useDevices(); // all device battery/state — live
useGuestbook({ limit: 20, offset: 0 }); // newest first, keeps previous page while loadingExample:
function ProfileCard({ id }: { id: string }) {
const { data, isLoading, error } = useDiscordUser(id);
if (isLoading) return <Spinner />;
if (error) return <p>{error.message}</p>;
return (
<div>
<img src={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";
function Stats({ uuid }: { uuid: string }) {
const { data } = useHypixelStats(uuid);
const player = getPlayerSummary(data); // { rank, networkLevel, karma, firstLogin, ... }
const profiles = 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:
function FrontList() {
const { data } = useFronters(); // updates live, no extra wiring
return (
<ul>
{data?.members?.map((m) => (
<li key={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";
function LivePresence({ 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) => {
const p = presences[id];
return (
<li key={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";
function Battery() {
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";
function LoginForm() {
const login = useLogin();
async function onSubmit(username: string, password: string, turnstileToken: string) {
const { access_token } = await login.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:
function SwitchButton({ ids }: { ids: string[] }) {
const setFronters = useSetFronters();
return <button onClick={() => 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";
const signup = useSignup();
const { correction_token } = await signup.mutateAsync({ username, password, email });
useVerifyEmail().mutate(tokenFromUrl); // confirm the address (no captcha)
useResetTokenValid(tokenFromUrl); // check a reset link before showing the form
useForgotPassword().mutate({ username }); // email a reset link
useResetPassword().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:
const tokenRef = useRef<string>("");
<DoughminationProvider turnstile={() => tokenRef.current}>
{/* render Turnstile's widget somewhere and set tokenRef.current in its callback */}
</DoughminationProvider>import { useGuestbook, useGuestbookPost } from "@doughmination/react-api";
function Guestbook() {
const { data } = useGuestbook({ limit: 20 });
const post = useGuestbookPost(); // turnstile from provider, or pass turnstileToken here
async function sign(name: string, message: string) {
const res = await post.mutateAsync({ name, message });
if (res.skipped) return; // honeypot tripped — API fakes success and drops it
}
return <>{data?.entries.map((e) => <p key={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 {
await post.mutateAsync({ name, message });
} catch (err) {
if (isDoughminationError(err)) {
if (err.isRateLimited) show("Slow down a moment.");
else if (err.isAuthError) show("Please log in again.");
else show(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";
const client = new DoughminationClient(); // reads need no config
const fronters = await client.getFronters();
const record = await client.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