') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); GitHub - doughmination/react-api: an attempt to make my own npm package lol · GitHub
Skip to content

Repository files navigation

@doughmination/react-api

Socket Badgenpm versionPublishtypes includedReact 18 | 19TanStack Query v5license

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 QueryClient and 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.

Install

npm i @doughmination/react-api @tanstack/react-query react
# or: bun add @doughmination/react-api @tanstack/react-query react

react and @tanstack/react-query are peer dependencies — the package uses your app's copies.

Quick start

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

Provider options

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

Query hooks

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 loading

Example:

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>);}

Hypixel helpers

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.

Realtime

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 (pingpong), and re-sends subscriptions after a reconnect.

Three event types are pushed to every client automatically — no subscription needed:

  • fronters_updateuseFronters() stays live
  • mental_state_updateuseMentalState() stays live
  • device_updateuseDevices() / 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_update event carries PluralKit's raw object, which is missing the tags/status enrichment 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.

Live presence

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

Live device state

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>);}

Any raw event

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

Authentication

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

Account recovery

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 password

Turnstile

Login, 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>

Guestbook post

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.

Error handling

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

Using the client without React

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");

CORS

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.

API surface

AreaHooks
DiscorduseDiscordUser, useDiscordUsers, useDiscordStatus
MinecraftuseMinecraftProfile, useHypixelStats, useMinecraftCapes
PluraluseFronters, useMembers, useMember, useMentalState, useSystem, useMemberStatus, useUserInfo
DevicesuseDevices, useDeviceState
GuestbookuseGuestbook, useGuestbookPost, useDeleteGuestbookEntry
Presence / realtimeusePresence, useUserPresence, useConnectionStatus, useDoughminationEvent
Auth & writesuseLogin, useSignup, useSetFronters, useSwitchFront, useSetMentalState, useReportDevice
Account recoveryuseVerifyEmail, useResendVerification, useCorrectEmail, useForgotPassword, useForgotUsername, useResetPassword, useResetTokenValid, useUsernameAvailable, useEmailAvailable

Escape hatches: useDoughminationClient() (the client), useDoughminationSocket() (the raw socket), queryKeys (for manual cache work).

Development

bun install
bun run typecheck
bun run build # tsup → dist/ (ESM + CJS + .d.ts)

Licence

ESAL-2.3

About

an attempt to make my own npm package lol

Resources

Stars

1 star

Watchers

0 watching

Forks

Contributors

Languages