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

@peerfold/api-client

v1.2.0

Published

Typed TypeScript client for the Peerfold public API — browser/Node/edge, zero runtime deps. Types derive from the @peerfold/api-spec zod schemas so they can never drift from the OpenAPI contract.

Readme

@peerfold/api-client

Typed TypeScript client for the Peerfold public API (E14). Works in the browser, Node, and edge runtimes; zero runtime dependencies (a thin wrapper over fetch).

npm install @peerfold/api-client
import { PeerfoldClient } from "@peerfold/api-client";

const client = new PeerfoldClient({
  baseUrl: "https://acme.site.hublms.com",
  learnerToken: myLearnerJwt,
});

const catalog = await client.catalog.list({ limit: 25 });

A note on naming (1.0.0). The classes are PeerfoldClient / PeerfoldError, the factory is createPeerfold, the wire headers are Peerfold-Version / X-Peerfold-* and the browser storage keys are peerfold.*. 0.1.x shipped the old HubLMS-prefixed names; 0.2.0 renamed them and the API kept answering the old wire spellings so a 0.1.1 page was not broken mid-flight. 1.0.0 closes that window: the API no longer emits HubLMS-Version and no longer reads X-HubLMS-Publishable-Key. Pin ^1.0.0.

| 0.1.x wire name | 1.0 name | Direction | |---|---|---| | HubLMS-Version | Peerfold-Version | response (and the request-side version pin) | | X-HubLMS-Publishable-Key | X-Peerfold-Publishable-Key | request |

Spec-first types (drift-impossible)

Every request/response type is z.infer<> of the exact zod schema @peerfold/api-spec uses to validate the live API — the same schemas the spec:check drift gate keeps welded to openapi.json. There is no hand-written mirror of the shapes in this package. The trick (src/types.ts):

import type { z } from "zod";
type Registry = typeof import("@peerfold/api-spec/schemas").schemaRegistry;
export type Member = z.infer<Registry["Member"]>;

typeof import(...) and import type are fully erased at compile time, so no value import of zod or api-spec survives into the emitted JS — the runtime keeps its zero-dependency promise while the types stay pinned to the OpenAPI contract. If a field changes in the spec, this type changes automatically and stale call sites fail to typecheck. (@peerfold/api-spec and zod are therefore devDependencies — compile-time only.)

Install & construct

import { PeerfoldClient } from "@peerfold/api-client";

// Browser / learner plane — configured with a short-lived learner JWT.
const client = new PeerfoldClient({
  baseUrl: "https://acme.site.hublms.com",
  learnerToken: myLearnerJwt, // string, or a () => string | Promise<string> provider
});

// Server / admin plane — configured with a secret key (NEVER ship to a browser).
const admin = new PeerfoldClient({
  baseUrl: "https://app.hublms.com",
  secretKey: process.env.PEERFOLD_SK, // sk_live_… / sk_test_…
});

// Commerce plane — configured with a PUBLISHABLE key, which is safe in a
// browser: it is designed to sit in page source. This is the plane that works
// with nobody signed in.
const shop = new PeerfoldClient({
  baseUrl: "https://acme.site.hublms.com",
  publishableKey: "pk_live_…",
});

The two browser credentials can live on one client, and several endpoints answer differently depending on which arrive — a bearer alongside a publishable key is answered on the learner plane, expired tokens and all. That precedence is the API's, and the client sends what it has rather than guessing.

fetch is injectable (fetch: myFetch) for edge runtimes or testing. Pin an API version with version: "2026-07-01" (sent as the Peerfold-Version header).

Learner plane

const me = await client.me.get();
await client.me.update({ name: "Lee" });

const page = await client.catalog.list({ limit: 25 });
for await (const course of client.catalog.iterate()) { /* every page */ }

const detail = await client.courses.get("intro-to-widgets");
const enrollment = await client.enrollments.create({ course_slug: "intro-to-widgets" });

// Idempotency-Key is REQUIRED by the API; the client auto-generates one
// (crypto.randomUUID) unless you supply your own — so retries are always safe.
await client.progress.record(enrollment.id, {
  event_id: "evt-123",
  type: "lesson_completed",
  lesson_id: "lesson-1",
});

const quiz = await client.quizzes.submit(enrollment.id, {
  lesson_id: "lesson-2", block_id: "q1", answers: { "question-1": ["a"] },
});

const exam = await client.exams.submit(enrollment.id, {
  lesson_id: "lesson-9", answers: { "question-1": ["a"] },
});

for await (const cert of client.certificates.iterate()) { /* … */ }
const verified = await client.certificates.verify("SER-1234"); // public, no auth

// Certificate sharing — the holder's own, and nobody else's. Enabling is
// idempotent; disabling REVOKES (a re-mint produces a different slug).
const { share_url } = await client.certificates.enableShare(cert.id);
await client.certificates.disableShare(cert.id);

Each certificate row already carries share_url (null until minted) and linkedin_add_url — the prefilled Add to profile link, composed server-side, or null when the workspace has the placement off, is not entitled to it, or the credential is revoked. Put it in an href; do not rebuild it.

The immersive layout, decided once

A course page is laid out one of two ways, and the rule is exported rather than described so three front ends cannot each hold a different copy of it:

import { isImmersiveCourse, isImmersiveStage } from "@peerfold/api-client";

const course = await client.courses.get("fire-safety");
if (isImmersiveCourse(course)) {
  // no course outline beside the lesson; the content takes the full width
}

A course is immersive when its layout is "immersive" (the author's toggle, read from the live course row, so it needs no republish) or when lesson_count is 1, which holds whatever the toggle says. Both planes carry both fields, so a page built with a publishable key and no learner token decides the same way a signed-in one does.

Keep your header and your footer. In a multi-lesson immersive course the footer's previous / next is the only navigation left. Nothing else moves: locks, progress writes and completion rules are identical either way.

isImmersiveStage(course, lesson) is the stronger variant, for handing a packaged lesson the rest of the viewport. It accepts every packaged format — SCORM 1.2, SCORM 2004, xAPI / Tin Can and cmi5 — which is the half a hand-rolled check gets wrong: kind is "scorm" for all four, and a check that tested only the SCORM formats once played an xAPI course boxed while its SCORM twin went full width. It answers false for a package that is missing or still importing, because a screen-tall band around one sentence of explanatory copy is worse than the ordinary stage.

Commerce plane (publishable key)

The endpoints a merchant's own page calls before anybody is signed in. A visitor who has never signed in can fill a cart and pay — which is what makes the guest checkout and its claim-link path reachable at all.

// The cart token is returned ONCE. It is a capability over one cart: it names no
// member and reaches no other cart. Persist it yourself (localStorage on your
// own origin — a Cloud cookie would be third-party there).
const { cart_token, cart } = await shop.cart.create({ course_id: courseId });
shop.setCartToken(cart_token);

await shop.cart.add(otherCourseId);
await shop.cart.remove(courseId);
const priced = await shop.cart.get();   // every line re-priced from the live row
const { checkout_url, order_id } = await shop.cart.checkout({ return_url });

// Send the buyer to `checkout_url`, then poll — `?status=success` in your own
// return URL is not proof of payment.
const { status, claim_email_masked } = await shop.orders.status(order_id);

const { data: tiers } = await shop.memberships.listTiers();
const join = await shop.memberships.join(tiers[0].id, { return_url });

const { data: programs } = await shop.programs.list();
const program = await shop.programs.get("compliance-track");
const buy = await shop.programs.checkout(program.id, { return_url });

Three things this plane guarantees, and they are the reason it is safe in a browser:

  • No request carries an amount and there is no field to put one in. Every price is read from the live row at mint time, server-side.
  • orders.status is silent in every direction. An order that does not exist, belongs to another workspace, or was never paid all answer pending, indistinguishably — so a long run of pending means "not yet", never "wrong id", and it never throws a 404 you could read as an answer.
  • programs sends whatever is configured. With a learner token as well as the key, the payload carries that learner's progress and a completed flag per course; with the key alone it carries neither.

Admin / server plane

// Mint a learner token for a browser session (PRD Flow 3).
const { access_token } = await admin.admin.auth.mintLearnerToken({ email: "[email protected]" });

for await (const learner of admin.admin.learners.iterate({ status: "active" })) { /* … */ }
const learner = await admin.admin.learners.create({ email: "[email protected]" });
await admin.admin.learners.enroll(learner.id, { course_slug: "intro-to-widgets" });

const reports = await admin.admin.reports.courses();
const { secret } = await admin.admin.webhooks.create({
  url: "https://api.example.com/hooks",
  events: ["certificate.issued", "course.published"],
});

Errors, rate limits, request ids

Every non-2xx response throws a PeerfoldError carrying the parsed RFC 7807 problem+json body:

import { PeerfoldError, getResponseMeta } from "@peerfold/api-client";

try {
  await client.progress.record(id, event);
} catch (err) {
  if (PeerfoldError.is(err)) {
    err.status;      // 429
    err.code;        // "rate_limited" (stable machine-readable code)
    err.requestId;   // echoed X-Request-Id
    err.rateLimit;   // { limit, remaining, reset }
    err.retryable;   // true for 429 / 5xx
  }
}

// Rate-limit / request-id metadata is also surfaced on successful results:
const page = await client.catalog.list();
getResponseMeta(page)?.rateLimit.remaining;
client.lastResponseMeta?.apiVersion;

Conventions baked in

  • Auth per plane — learner methods send the learner JWT; admin methods send the secret key; calling a plane whose credential is missing throws before any network I/O. Public certificate verification needs no credential.
  • Cursor pagination — every list has .list({ cursor, limit }) and a .iterate() async iterator that walks all pages.
  • Idempotency — auto Idempotency-Key on progress.record (override via { idempotencyKey }).
  • Edge-safe — only web-standard globals (fetch, Headers, crypto, AbortSignal); no Node-only APIs in the default path.