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

@waukeshamakerspace/roster-client

v0.1.0

Published

One HTTP client for Roster, the Waukesha Makerspace person system of record: people, roles, lookup, search and login-time resolve, with the wire quirks (API key header, HTML-at-200 origin, envelope, camel/snake asymmetry) handled once

Readme

@waukeshamakerspace/roster-client

One HTTP client for Roster, the Waukesha Makerspace person system of record.

Four services (gatehouse, journeyman, atlas, keymaster) each grew their own Roster client. All four sent the same X-API-Key header to the same /api/people routes, three of them separately normalised the same wire asymmetry, only one guarded against the origin answering HTML at 200, and only one had a timeout and retries. This package is that code, once: the wire mechanics are fixed, and what legitimately differed between the apps (what to do when Roster cannot answer, retry, timeout, caching) is an option.

Zero runtime dependencies: global fetch on Node 20 or later.

Install

npm install @waukeshamakerspace/roster-client

Inside the web-platform monorepo, depend on it with "workspace:*".

Use

import { HttpRosterClient } from '@waukeshamakerspace/roster-client';

const roster = new HttpRosterClient({
  apiKey: process.env.ROSTER_API_KEY ?? '',
  // baseUrl defaults to https://roster.waukeshamakers.com
});

const roles = await roster.rolesFor(personId); // ['member', 'board']
const person = await roster.getPerson(personId); // { id, displayName, email, roles, ... } or null

Code against the RosterClient interface and inject the implementation, so tests can hand in the StubRosterClient below.

Methods

Every method sends X-API-Key, checks the content-type before parsing (the Roster origin serves its admin SPA as HTML with status 200 for unknown paths), unwraps the { success, data } envelope and treats success: false as a failure, and normalises Roster's list/detail asymmetry (snake_case display_name plus role strings on the list route, camelCase displayName plus role grant objects on the detail route) into the types in types.ts.

| Method | Endpoint | Returns | Empty value under the null posture | | --- | --- | --- | --- | | rolesFor(id) | GET /api/people/:id/roles | string[] \| null | null | | getPerson(id) | GET /api/people/:id | Person \| null (null on 404 in both postures) | null | | displayNames(ids) | GET /api/people?ids=a,b (100 per request, batched above that) | Map<id, string \| null> (unknown ids map to null) | a map of nulls | | search(query, { limit, offset, role }) | GET /api/people?search= | { items: PersonListItem[], total } | { items: [], total: 0 } | | lookup(criteria) | GET /api/people/lookup | LookupMatch[] (bare rows, no emails or roles) | [] | | resolve(input) | POST /api/people/resolve (needs the roster:resolve scope) | { personId, linkStatus, created } | always throws | | isActiveMember(id) | derived from rolesFor | boolean | always false (fail closed, never throws) |

Round trips that cannot succeed are skipped: lookup with no usable criterion (Roster would answer 400), search with an empty query and no role filter (a role on its own is a legitimate "list the holders"), a blank id, an empty id list.

Options

new HttpRosterClient({
  apiKey,                 // required; the caller reads its own env var
  baseUrl,                // default https://roster.waukeshamakers.com; trailing slash stripped; do not append /api
  fetchFn,                // injectable for tests
  timeoutMs,              // per attempt, via AbortSignal.timeout; undefined (default) means none
  retry,                  // off by default; {} turns on keymaster's backoff with every default
  cache: {
    rolesTtlMs,           // 0 (default) disables
    namesTtlMs,           // 0 (default) disables
    cacheFailures,        // default false; true also caches a failed answer as null for the TTL
  },
  unavailable,            // 'throw' (default) or 'null'; each call can override
  rolesNotFound,          // 'unavailable' (default, follows the posture) or 'empty' (a 404 on roles is [])
  onUnavailable,          // called when a failure is swallowed under the null posture; default console.warn
});

The posture: unavailable

"Unavailable" is every way of not getting a clean answer: a transport failure or timeout, a non-2xx status (other than the 404s named above), a non-JSON body, an unsuccessful envelope, a missing API key. Under 'throw' the method raises a RosterError (one class, with status, code and cause). Under 'null' it calls onUnavailable and returns its empty value from the table.

The posture is a constructor default with a per-call override, because an app can need both: gatehouse's ingest path must never 500 on a Roster blip (lookup and search are soft) while its live admin re-verification must fail closed (rolesFor and getPerson are hard).

await roster.lookup({ email }, { unavailable: 'null' });
await roster.rolesFor(id, { unavailable: 'throw' });

Only a RosterError is ever swallowed; any other error is a bug and propagates whatever the posture. resolve always throws, and isActiveMember never does.

Retry

retry: {} turns on the policy keymaster used: 3 attempts, 1 s initial delay doubling to a 10 s cap, plus or minus 25 percent jitter. It retries an HTTP 5xx or 429 and a network-level failure (fetch rejecting with a TypeError). It does not retry a 4xx, a non-JSON body, an unsuccessful envelope, or a per-attempt timeout; pass isRetryable to change that. Every field of RetryOptions (maxAttempts, initialDelayMs, maxDelayMs, backoffMultiplier, jitter, isRetryable, onRetry) is optional.

Caches

Both caches are per client instance and keyed by person id. rolesTtlMs caches rolesFor (and therefore isActiveMember); namesTtlMs caches displayNames, including the ids Roster did not know. With cacheFailures: true a failed answer is cached as null for the TTL too, so a down Roster is not hammered by a burst of writes; that is atlas's choice, and it means a blip denies for one TTL. clearCache() drops both.

The four apps as worked examples

Each app keeps the posture it has today and keeps reading its own env var.

// gatehouse: ingest reads soft, admin re-verification hard, no timeout today
const roster = new HttpRosterClient({
  apiKey: process.env.ROSTER_API_KEY ?? '',
  baseUrl: process.env.ROSTER_URL,
});
await roster.lookup(criteria, { unavailable: 'null' });
await roster.search(query, { limit: 10, unavailable: 'null' });
await roster.getPerson(id); // throws on an outage, null on a 404
await roster.rolesFor(id); // throws on an outage: fail closed

// journeyman: names and roles soft, member search hard, names cached 15 min
const roster = new HttpRosterClient({
  apiKey: process.env.KEYMASTER_API_KEY ?? '',
  baseUrl: process.env.ROSTER_URL,
  unavailable: 'null',
  cache: { namesTtlMs: 15 * 60_000 },
});
await roster.displayNames(ids);
await roster.rolesFor(id); // null means "could not check": the write path must deny
await roster.search(query, { role: 'member', limit, offset, unavailable: 'throw' });

// atlas: roles soft, 1.5 s timeout, 45 s cache that also remembers failures
const roster = new HttpRosterClient({
  apiKey: process.env.ROSTER_API_KEY ?? '',
  baseUrl: process.env.ROSTER_URL,
  unavailable: 'null',
  timeoutMs: 1_500,
  rolesNotFound: 'empty',
  cache: { rolesTtlMs: 45_000, cacheFailures: true },
});
await roster.isActiveMember(sub); // false on any failure: guards high-stakes writes

// keymaster: resolve at login with retries and a 5 s timeout, always throws
const roster = new HttpRosterClient({
  apiKey: config.roster.apiKey,
  baseUrl: config.roster.url,
  timeoutMs: 5_000,
  retry: { onRetry: (error, attempt, delay) => console.warn(`Roster retry ${attempt} after ${delay}ms`, error) },
});
const { personId, linkStatus } = await roster.resolve({ provider, providerUserId, email, emailVerified });

Testing against it

StubRosterClient implements RosterClient in memory. Arrange answers with the given* builders, then assert on the recorded calls (lookupCalls, searchCalls, getPersonCalls, rolesForCalls, displayNamesCalls, resolveCalls), for example that Roster was not consulted when a local mapping already covered an event.

import { RosterError, StubRosterClient } from '@waukeshamakerspace/roster-client';

const roster = new StubRosterClient();
roster.givenEmailMatches('[email protected]', [{ id: 'p1', displayName: 'Ada', wildApricotId: null, keymasterUserId: null }]);
roster.givenPerson('p1', { displayName: 'Ada', email: '[email protected]', roles: ['member'] });
roster.givenRoles('p2', ['board']);
roster.givenSearchMatches('ada', [item]);
roster.givenResolve({ personId: 'p1', linkStatus: 'active', created: false });

roster.givenRolesFail(new RosterError('Roster is down', { status: 503 }));

Failures honour the same posture as the real client: under 'throw' (the default, or new StubRosterClient({ unavailable: 'null' }) to change it) a given*Fails error is thrown as given; under 'null' the method returns its empty value. reset() forgets everything.

Consumers that inject their own fetchFn can drive HttpRosterClient directly; this package's own tests do exactly that.

Types

Person (detail), PersonListItem (list), LookupMatch (lookup's bare rows), Role (a grant as Roster returns it), LookupCriteria, SearchOptions, SearchResult, ResolveInput, ResolveResult, CallOptions, Unavailable, RosterClientOptions, CacheOptions, RetryOptions. roleNames(value) is the role normaliser (strings, grant objects, or a mix) for anyone parsing a Roster payload by hand.