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

@hadoku/prefs-client

v1.1.27

Published

Client SDK for the hadoku unified preferences store. Wraps /prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a usePrefs() React hook, plus match history, ratings and leaderboards for every game (./games), the one resolv

Readme

@wolffm/prefs-client

Client SDK for the hadoku unified preferences store. Wraps hadoku.me/prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a React usePrefs() hook.

See docs/planning/unified-prefs-2026-05-20.md for the architectural background.

Install

pnpm add @wolffm/prefs-client zod
# React consumers also:
pnpm add react

Usage

import { z } from 'zod';
import { createPrefsClient } from '@wolffm/prefs-client';
import { usePrefs } from '@wolffm/prefs-client/react';

const TaskPrefsSchema = z.object({
  theme: z.string().optional(),
  themeMode: z.enum(['simple', 'advanced']).optional(),
  alwaysVerticalLayout: z.boolean().optional(),
});

export const taskPrefs = createPrefsClient({
  appId: 'task',
  schema: TaskPrefsSchema,
  defaults: { theme: 'light', themeMode: 'simple' },
  migrations: [
    (blob) => {
      if ('simpleMode' in blob) {
        blob.themeMode = blob.simpleMode ? 'simple' : 'advanced';
        delete blob.simpleMode;
      }
      return blob;
    },
  ],
  bootstrapToSessionStorage: { theme: 'hadoku-theme' },
});

function Settings() {
  const { prefs, save, loading } = usePrefs(taskPrefs);
  if (loading || !prefs) return null;
  return (
    <ThemeToggle
      value={prefs.theme}
      onChange={(t) => save({ theme: t }, { scope: 'device' })}
    />
  );
}

Key behaviours

  • Cache: localStorage at prefs-cache:{userId}:{appId}. Stale-while-revalidate.
  • Writes: 1s-debounced PUT. Multiple save() calls within the window coalesce into one network call.
  • Conflicts: API-Always-Wins. On a 409 the SDK adopts the server blob and re-renders; the local patch is discarded, and the save() (with an explicit expectedVersion) or flush() waiting on it rejects with PrefsConflictError.
  • expectedVersion: by default the SDK sends the last version it has seen (0 before the first read). save(patch, { expectedVersion: n }) sends n; { expectedVersion: null } sends none — last write wins. Both send at once rather than debounced.
  • Cross-tab: BroadcastChannel keyed by prefs:{userId}:{appId} propagates saves between tabs without a server roundtrip.
  • Anon → authed: call client.promoteAnonPrefsToUser() after sign-in. If the server already has user-scope prefs (signed in on another device first), the server wins.
  • Dirty-field protection: a background refresh (or an incoming cross-tab broadcast) never overwrites a field with a save still pending/in-flight — it merges the server/remote blob with your unconfirmed local value instead. Otherwise a refresh landing between an optimistic write and its debounced PUT would visibly revert the change.
  • FOUC bootstrap: bootstrapToSessionStorage mirrors specific fields to sessionStorage so the inline <head> script can apply theme before React mounts. Mirroring only happens on an explicit local save() by default — never on read/hydrate/refresh/broadcast-receive — so an app embedded under a host that owns the same sessionStorage key can't clobber it just by booting. Pass mirrorBootstrapOnRead: true to restore the old every-read mirroring for a key nothing else writes to.

Capacitor (native APK)

Storage is auto-detected: in a native Capacitor webview the SDK reads the Capacitor.Plugins.Preferences global and uses it for the cache; in a browser it uses localStorage. No configuration, no import, and no @capacitor/preferences dependency added to web bundles — the detection is a runtime global read, so web-only consumers are unaffected.

Requirements for the native side:

  • The host APK must have @capacitor/preferences installed (so Capacitor registers the plugin global). The SDK does not bundle or import it.
  • The APK mints its own hadoku_device_id in native storage, separate from the mobile browser's — so the native app and mobile Safari are two distinct devices by design.

You can still override with an explicit adapter (or force one in tests) via createPrefsClient({ storage }). detectStorage() and capacitorStorage() are exported if you want to resolve the adapter yourself.

Header-mode (service-to-service, no cookie)

Pass userKey when there's no hadoku_session cookie to ride (e.g. a CLI or a native app that authenticates with a raw key instead of the cookie):

const client = createPrefsClient({
  appId: 'task-mobile',
  schema: TaskPrefsSchema,
  userKey: process.env.HADOKU_API_KEY,
  // storage auto-detects Capacitor; pass an explicit adapter only to override
});

Platform rows (@wolffm/prefs-client/platform)

Rows that belong to no single app. Import the definition rather than retyping the appId, scope and schema, so the second app to need a setting finds the first one's row instead of inventing its own.

| Row | Fields | Scope | Owner | | --------------------- | ------------------------ | ------ | ---------------------------------------------------------- | | games (GAMES_ROW) | muted, volume (0..1) | device | every hadoku game: master sound, above a game's own levels | | portfolio | theme | device | @wolffm/themes; do not write it from an app |

import { createPrefsClient } from '@wolffm/prefs-client';
import { GAMES_ROW, GAMES_DEFAULTS } from '@wolffm/prefs-client/platform';

const games = createPrefsClient({ ...GAMES_ROW, defaults: GAMES_DEFAULTS });
await games.save({ muted: true }, { scope: GAMES_ROW.scope });

The profile picture is not a row here: it is ./avatar below, whose fallback is the watchparty character.

Avatars (@wolffm/prefs-client/avatar)

One resolved avatar per hadoku user, chosen by the user in the fleet settings popout (ConnectedSettings, @wolffm/task-ui-components): the picture's source and their character.

interface ResolvedAvatar {
  source: 'sprite' | 'discord' | 'steam' | 'upload'; // what the picture is
  url?: string; // present exactly when source !== 'sprite': square https image, >= 184px
  sprite?: string; // the person's character, WHATEVER the source; absent = your default
}

Browser, the signed-in user's own avatar:

import { createAvatarClient } from '@wolffm/prefs-client/avatar';
const { avatar } = await createAvatarClient().get(); // AvatarSettings

Server, everyone in a room, by userId, with the server's service-tier key:

import { resolveAvatars } from '@wolffm/prefs-client/avatar';
const byUser = await resolveAvatars(
  { serviceKey: process.env.SERVICE_KEY! },
  userIds
);
// every requested id is present; unknown users resolve to { source: 'sprite', name: null }
byUser[id].name; // their live display name, or null for an unknown or closed account

Names come from here too. A server stores only userIds (IDENTITY_MODEL R12) and names people, online or not, when it shows them: resolveAvatars(...)[id].name. Never keep a copy. prefs-api reads the registry at most once a minute per isolate, so a rename shows within a minute and a deleted account stops being named. Over HTTP it is POST /prefs/api/v1/_avatar/resolve { userIds } with a service-tier X-User-Key.

Rules for an app that renders avatars:

  • url → <img src>, nothing else: never fetch it server-side, never proxy it. Set referrerpolicy="no-referrer". A URL can stop working (a changed Discord picture, an admin removal): on error, fall back to the sprite.
  • sprite is the character even when the picture is a Discord avatar: a game uses it as the piece and its colour. The app that owns the art builds its URL (watchparty owns art revisions; its public GET /watchparty/api/characters lists them). Without one, use your own default.
  • Do not use Steam's or Discord's logos, and do not imply either endorses the app.
  • Resolve on join and on reconnect; avatars change rarely. resolveAvatars batches past 100 ids.
  • Source choice, character choice, linking (linkInPopup: no page navigation), uploads and removal all happen in the settings popout. Don't build your own.

Avatar cosmetics (@wolffm/prefs-client/cosmetics)

What a person wears with their avatar: a name effect, a name gradient, a name font, an avatar overlay (an image the person can move, turn and resize, or a drawn ring or glow), and a message reveal for their chat lines. Items are earned or gifted, never bought; one per slot. People equip them in the settings popout's avatar dialog. Every resolved avatar carries what is worn as cosmetics (ResolvedAvatar.cosmetics, only items the person owns).

import {
  ensureCosmeticsStyles,
  colourStyle,
  nameHtml,
  avatarHtml,
  messageHtml,
} from '@wolffm/prefs-client/cosmetics';

ensureCosmeticsStyles(); // once: the stylesheet, and the clean-up that ends a message reveal
const worn = person.cosmetics; // from resolveAvatars() or the owner's settings
row.style.cssText = colourStyle(character.color); // every text colour derives from this
row.innerHTML =
  avatarHtml({ src: pictureUrl, size: 40, worn }) +
  nameHtml(person.name, worn) +
  messageHtml(text, live ? worn : undefined); // reveal only messages arriving live

Rules for an app that renders them:

  • Colour comes from the character. Put the character's colour in --hc-base on the row (colourStyle). Never give a name effect or gradient its own colour.
  • Reveal live messages only, never history or a scroll-back load. The reveal ends as plain text.
  • Pause what is off screen: toggle hc-idle on rows an IntersectionObserver reports hidden.
  • Leave room for overlays. An overlay may reach about a third of the avatar's size above it and a quarter to each side (PLACEMENT_ZONE); don't clip the avatar's box.
  • Images and fonts load same-origin from /cosmetics/ (hadoku_site public/cosmetics); a font downloads only when someone wears it.
  • Adding an item: COSMETICS in cosmetics-contract.ts, its CSS in cosmetics.ts, an image in public/cosmetics/art/. test/cosmetics.test.ts fails if any of the three is missing, and if a loop animates anything but transform or opacity.