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

@trainheroic-unofficial/js

v4.0.0

Published

Unofficial TypeScript SDK for the TrainHeroic API.

Readme

@trainheroic-unofficial/js

An unofficial TypeScript SDK for the TrainHeroic API. The client covers auth and session renewal across both TrainHeroic hosts, a searchable exercise library, workout encoding, and messaging. It runs in any modern JavaScript runtime, including Cloudflare workerd.

Part of the trainheroic-unofficial workspace.

Unaffiliated with TrainHeroic. It drives the same undocumented endpoints the web app uses, so a server-side change can break it without warning. Use it against your own account.

Contents

Install

npm install @trainheroic-unofficial/js
# or: pnpm add @trainheroic-unofficial/js

Requires a runtime with global fetch and Web Crypto (Node >= 18, workerd, modern browsers).

Quickstart

Construct a client with your TrainHeroic credentials and call request. The client logs in lazily on the first call and renews the session for you.

import { TrainHeroicClient } from "@trainheroic-unofficial/js";

const client = new TrainHeroicClient(
  process.env.TRAINHEROIC_EMAIL!,
  process.env.TRAINHEROIC_PASSWORD!,
);

// request<T>(method, path, options) reaches any endpoint the web app uses.
// The type parameter T types res.data; omit it and data is `unknown`.
const res = await client.request<{ id: number }>("GET", "/user/simple");
if (res.ok) {
  console.log(res.data.id);
}

request returns { status: number; ok: boolean; data: T }. It does not throw on an HTTP error status, so check res.ok. It throws TrainHeroicAuthError only when a login attempt fails (bad credentials).

The third argument, options, is RequestOptions:

type RequestOptions = {
  body?: unknown; // serialized as JSON for non-GET/DELETE requests
  base?: "coach" | "apis"; // which host; defaults to "coach" (api.trainheroic.com)
};

So a write is the same call with a body:

const created = await client.request("POST", "/2.0/coach/exercise/create", {
  body: { title: "Sled Push", points_of_performance: "" },
});

The paths are the ones the TrainHeroic web app calls. Most are reachable through the typed helpers below, so you rarely call request directly. The request and response shapes those helpers use live in @trainheroic-unofficial/dto as zod schemas and types.

Reusing a session across restarts

TrainHeroic has no refresh token and a session expires after roughly one or two hours. By default the client logs in on its first request and keeps the session token in memory for the life of the process, so a fresh process logs in again. To skip that login, read the token off one client and hand it to the next via the third constructor argument.

After a request has run, client.sessionId holds the active token, typed string | null (it is null only before the first login). Persist it with your own storage; saveToken and loadToken below stand in for whatever you use (a file, a KV store, an env var):

await client.request("GET", "/user/simple");
const token = client.sessionId;
if (token) saveToken(token);

A later process passes the saved token as the third constructor argument (also string | null, so a missing token simply falls back to a normal login):

const client = new TrainHeroicClient(
  process.env.TRAINHEROIC_EMAIL!,
  process.env.TRAINHEROIC_PASSWORD!,
  loadToken(),
);

If the reused token has expired, the next request gets a 401/403; the client logs in once with the credentials, retries, and updates client.sessionId to the new token.

The optional fourth constructor argument can persist renewed sessions and report final HTTP failures without coupling the SDK to a telemetry vendor:

const client = new TrainHeroicClient(email, password, savedSession, {
  onSession: saveSession,
  onHttpError: (error) => telemetry.captureException(error),
  transport: (url, init) => fetch(url, init), // optional host-specific HTTP transport
});

The client keeps at most four API operations in flight. A custom transport receives the same URL and RequestInit that the default global fetch would receive; hosted runtimes can use this seam to add coordination without changing SDK authentication or response parsing.

onHttpError receives a TrainHeroicHttpError containing the method, status, host, a bounded request-body summary, and sanitized provider response diagnostics. The request summary records field names, array lengths, a derived date-span count, and a small allowlist of non-sensitive enum values; arbitrary request values are never included. Response diagnostics retain bounded provider error strings, status fields, and boolean success flags. Credential values, email addresses, IPv4 addresses, and SSNs are scrubbed from diagnostic strings, and unknown response fields are omitted. Paths, query strings, session tokens, and login request and response data remain excluded. A transient 401/403 that succeeds after automatic re-login does not call the hook, and synchronous or asynchronous hook failures never change the request result.

Two entry points

// Runtime-agnostic. Safe in browsers and on workerd.
import { TrainHeroicClient, ExerciseLibrary, buildSession } from "@trainheroic-unofficial/js";

// Node-only filesystem helpers, kept out of the main entry.
import { JsonFileLibraryCache, defaultCachePath } from "@trainheroic-unofficial/js/node";

The . entry imports no node:* modules. Anything that touches the filesystem lives behind ./node.

What it covers

  • Client and auth. TrainHeroicClient holds the coach credentials, acquires a session token lazily, and renews it transparently. TrainHeroic issues no refresh token, so on a 401/403 the client logs in again with the stored credentials and retries once. A cold client hit by concurrent requests performs a single shared login. RequestOptions.base selects the host (coach for api.trainheroic.com, apis for apis.trainheroic.com).
  • Exercises. ExerciseIndex is the interface the rest of the system codes against; ExerciseLibrary is the in-memory implementation, handling name-to-id resolution, fuzzy search ranking, and persistence through a LibraryCache (in-memory by default, JSON file via ./node). The hosted server supplies a D1-backed implementation of the same interface.
  • Workouts. A session builder that creates a session, saves blocks and exercises, and optionally publishes, along with read-back, instruction editing, and removal. Includes the encoder that turns a WorkoutSpec into the API's payload.
  • Athlete training. Functions for the logged-in account's own training, covering scheduled and completed workouts, per-exercise history, personal records, and working maxes.
  • Analytics. Coach analytics reports via queryAnalytics, with analyticsMetricCatalog and ANALYTICS_METRIC_KEYS for discovery. teamVolume rolls up per-athlete training summary rows into a team total. Large training summaries are read as sequential five-athlete, 90-day batches and merged into one report.
  • Messaging. Tools for conversation streams: listing them, reading a stream, and building or sending or deleting a comment.

createProgram(client, { kind, name }) creates a standalone calendar or fixed program and returns both containerId (the id in GET /1.0/coach/programs) and programId (the id used by program detail and workout writes). TrainHeroic may replace the requested name; inspect the returned title and nameApplied. Creation is not idempotent, so do not blindly retry an uncertain result. deleteProgram(client, programId) removes it via DELETE /v5/programs/{id} and accepts either id (a container id is resolved to group_program first).

Working with exercises

ExerciseLibrary loads the full library once, caches it, and answers name lookups and fuzzy search against the in-memory copy. By default it caches in memory; pass a JsonFileLibraryCache from ./node to persist between runs (it writes to defaultCachePath(), ~/.trainheroic/library.json, unless you pass a path). create / update / remove write through to TrainHeroic (POST /2.0/coach/exercise/create, POST /2.0/coach/exercise/update/{id}, DELETE /v5/exercises/{id}); recordDelete only drops the cached row.

import { ExerciseLibrary } from "@trainheroic-unofficial/js";
import { JsonFileLibraryCache } from "@trainheroic-unofficial/js/node";

// `client` is the TrainHeroicClient from the Quickstart above.
const library = new ExerciseLibrary(client, new JsonFileLibraryCache());

// Fuzzy search, ranked. Returns up to `limit` matches, each with id, title, and units.
const matches = await library.search("back squat", 5);

// resolve() returns { match, candidates }. A single confident hit fills `match`;
// an ambiguous name leaves `match` null and returns the candidates to choose from.
const { match, candidates } = await library.resolve("Barbell Back Squat");
if (!match) {
  // Ask the user (or the model) to pick one of `candidates`, then use its id.
  console.log(candidates.map((c) => `${c.id}: ${c.title}`));
}

Building a workout

buildSession writes one session into a program on a given day: it creates the session, saves the blocks and exercises, and optionally publishes. programId identifies one of your TrainHeroic programs (find it in the program's URL in the web app, or from a programs read via client.request("GET", ...)). Exercise ids come from the library.

import { buildSession, type BlockSpec } from "@trainheroic-unofficial/js";

const { match } = await library.resolve("Back Squat");
if (!match) throw new Error("Resolve to a single exercise before building.");
if (match.units[0] !== "reps" || match.units[1] !== "lb") {
  throw new Error("Choose an exercise with reps and pounds as its fixed units.");
}

const blocks: BlockSpec[] = [
  {
    title: "Strength",
    exercises: [
      // sets/reps/weight scalars; rpe is routed into the instruction text (see the encoder).
      {
        id: match.id,
        sets: 5,
        reps: 5,
        primaryUnit: "reps",
        weight: 225,
        secondaryUnit: "lb",
        rpe: 8,
      },
    ],
  },
];

const { pwId, workoutId } = await buildSession(client, {
  programId: 12345,
  index: library,
  date: [2026, 6, 22], // [year, month, day]; month is 1-based, so 6 = June
  blocks,
  instruction: "Warm up first.",
  publish: false, // build as a draft; publish makes it visible to athletes
});

Each exercise needs an id; sets, reps, weight, rpe, and a per-exercise instr are optional. reps and weight take a scalar (broadcast across every set) or a per-set array like reps: [5, 5, 3]. State primaryUnit for reps and secondaryUnit for weight. The full field list is ExerciseSpec / BlockSpec in @trainheroic-unofficial/dto. buildSession checks those units against the exercise library and rejects a mismatch before writing.

buildSession returns { pwId, workoutId, advisories }: pwId is the program-workout id (the placement of the session in the program, which is the handle subsequent calls take), and workoutId is the underlying workout id. Use pwId with readSession(client, programId, date, pwId) to read the session back. publishSession(client, pwId) publishes it later; removeSession(client, programId, pwId) deletes it.

Reading athlete training

import { resolveAthleteUserId, fetchAthleteProfileSummary } from "@trainheroic-unofficial/js";

const userId = await resolveAthleteUserId(client);
const summary = await fetchAthleteProfileSummary(client, userId);

These work from any session, coach or athlete, since a coach account also carries athlete scope.

Analytics

Coach analytics reports are read-only data pulls. TrainHeroic uses POST for these queries, but they do not mutate anything. The metric keys in ANALYTICS_METRIC_KEYS are the SDK catalog — they are not the raw category names from GET /v5/analytics. Use analyticsMetricCatalog() to see each metric's scope and required inputs.

Team metrics (readiness-team, compliance-team, lift-progress-team) need teamId. Athlete metrics (readiness-athlete, training-summary-athlete, lift-1rm-history, working-max-history) need one or more userIds in a single call (the report returns a row per athlete). readiness-team takes a single date; every other metric takes dateStart and dateEnd. Lift metrics also need exerciseId. All dates are YYYY-MM-DD.

There is no team-wide training volume metric. Pass every athlete's id to training-summary-athlete, or call teamVolume() to group those rows into a team rollup.

import { analyticsMetricCatalog, queryAnalytics } from "@trainheroic-unofficial/js";

// Read-only report (TrainHeroic uses POST for these queries).
const readiness = await queryAnalytics(client, {
  metric: "readiness-team",
  teamId: 42,
  date: "2026-06-22",
});
console.log(readiness);

// Scope + required params for every metric key:
console.log(analyticsMetricCatalog());

The workout encoder

buildSession calls the encoder for you; you only deal with it directly to preview values. TrainHeroic's exercise payload expects every parameter slot present, so the encoder fills all of them (empty slots included) to avoid an HTTP 500. A scalar prescription is broadcast across the set count. RPE goes into the instruction text because the API coerces a numeric slot to load. Each populated slot in a spec states its intended unit. collectAdvisories rejects a unit that differs from the exercise's fixed parameter type before a workout is written.

buildSession calls collectAdvisories(blocks, index) before its first write and returns its non-blocking notes and warnings in advisories. Call collectAdvisories directly to preview validation without writing a workout. Validation fetches current units from TrainHeroic; an unavailable library response stops the build.

Develop

Clone the workspace and run pnpm install once at the repo root (Node >= 24, pnpm 11), then from this package directory:

pnpm build       # tsdown -> dist (separate "." and "./node" outputs)
pnpm typecheck
pnpm test
pnpm exec vitest run test/workout-encode.test.ts          # one file
pnpm exec vitest run -t "broadcasts a scalar over sets"   # one test

License

MIT