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

online-chess-api

v0.1.1

Published

Unified TypeScript wrapper for the Lichess and Chess.com public APIs: one data model for users, ratings, stats, games, time controls and speeds.

Downloads

233

Readme

chess-api

One unified TypeScript wrapper over the Lichess and Chess.com public APIs.

Both platforms expose similar data in incompatible shapes — different field names, different time-control notations, different rating-pool keys, different timestamp units and completely different game-termination vocabularies. This library normalizes all of it into a single model so you can write platform-agnostic code.

  • Zero runtime dependencies (native fetch, runs TypeScript directly on Node ≥ 23.6)
  • Full raw clients too, if you want the platform-native shapes
  • Serialized + retrying HTTP layer (both APIs ask for one request at a time)
  • NDJSON streaming for large Lichess exports
import { createChessApi } from './src/index.ts';

const api = createChessApi({ userAgent: 'my-app ([email protected])' });

const player = await api.getPlayer('erik', 'chesscom');   // UnifiedPlayer
const stats  = await api.getStats('thibault', 'lichess'); // UnifiedStats
const games  = await api.getGames('hikaru', { max: 20 }); // UnifiedGame[]
$ node examples/demo.ts thibault erik

=== CHESSCOM: erik
    games: 14990 (rated 14990) — 8073W/6260L/657D
    bullet            1712  ± 42    8110 games  4187W/3700L/223D    peak 2071
    correspondence    1474  ± 62    5452 games  3085W/1995L/372D    peak 2065
    ...
=== Recent games (chess.com, normalized)
    date       platform  speed          time      variant     result     status
    2026-08-05 chesscom  correspondence 7d/move   standard    white won  resignation
    2026-08-01 chesscom  correspondence 1d/move   chess960    black won  resignation

The unified model

Time controls — TimeControl

The single hardest thing to unify. Sources:

| Platform | Raw | Meaning | |---|---|---| | Lichess | clock: { initial: 180, increment: 2, totalTime: 260 } | seconds (limitSeconds/incrementSeconds in lila) | | Lichess | daysPerTurn: 3 | correspondence | | Chess.com | "180+2" | 3 min + 2 sec increment | | Chess.com | "600" | 10 min, no increment | | Chess.com | "1/259200" | PGN moves-per-period: 1 move per 259200s = 3 days/move | | Chess.com | "40/7200+30" | PGN moves-per-period with increment: 2h base, +30s |

All become:

interface TimeControl {
  kind: 'clock' | 'correspondence';
  initial: number | null;               // seconds
  increment: number | null;             // seconds
  daysPerTurn: number | null;           // correspondence
  estimatedTotalSeconds: number | null; // initial + 40*increment (clock games)
  secondsPerMove: number | null;        // days * 86400 (correspondence only)
  label: string;                        // "3m+2s", "10m", "1m", "3d/move"
}

Note: Lichess timestamps (createdAt, lastMoveAt, since, until) are in milliseconds, while its clock values are in seconds. Chess.com timestamps are in seconds. The wrapper converts everything to Date.

Helpers:

parseTimeControl('1/259200'); // { kind:'correspondence', daysPerTurn:3, label:'3d/move' }
parseTimeControl('180+2');    // { kind:'clock', initial:180, increment:2, label:'3m+2s' }
clockToTimeControl(180, 2);   // from a Lichess clock (seconds)
daysPerTurnToTimeControl(3);
classifyClockSpeed(182);      // 'blitz'
timeControlToSpeed(tc);       // 'blitz' | ... | 'correspondence'

Time formats (speeds) — Speed

ultraBullet | bullet | blitz | rapid | classical | correspondence

Chess.com's time_class maps in: daily → correspondence, standard → classical, ultrabullet → ultraBullet, others 1:1. When time_class is missing the speed is derived from the clock using Lichess's own estimate of a 40-move game, initial + 40 × increment — the value Lichess reports as clock.totalTime:

| estimate | speed | |---|---| | < 30s | ultraBullet | | < 180s | bullet | | < 480s | blitz | | < 1500s | rapid | | >= 1500s | classical |

Using initial + increment instead is wrong: 2+2 would come out as bullet, while Lichess (and this library) classify it as blitz (120 + 40×2 = 200).

Rating pools — PerfKey

Speed | Variant | 'puzzle'. Chess.com's {rules}_{time_class} keys collapse:

| Chess.com | unified | |---|---| | chess_bullet / chess_blitz / chess_rapid | bullet / blitz / rapid | | chess_daily | correspondence | | chess960_daily | chess960 | | bughouse_daily | bughouse | | kingofthehill_daily | kingOfTheHill | | threecheck_daily | threeCheck | | crazyhouse_daily | crazyhouse | | tactics / lessons / puzzle_rush | stats.tactics / .lessons / .puzzleRush |

Lichess perf keys map 1:1 (storm/racer/streak are skipped).

Variants — Variant

standard | chess960 | crazyhouse | bughouse | antichess | atomic | horde | kingOfTheHill | racingKings | threeCheck | unknown (from Lichess variant and Chess.com rules, where chess → standard).

Game endings — UnifiedGameStatus

Lichess reports one status per game; Chess.com reports a result code per player. Both fold into one status plus a per-player win/loss/draw:

| Unified | Lichess | Chess.com (loser's code) | |---|---|---| | checkmate | mate | checkmated | | resignation | resign | resigned | | timeout | timeout | timeout | | out-of-time | outoftime | — | | agreed | draw | agreed | | stalemate | stalemate | stalemate | | repetition | — | repetition | | insufficient-material | insufficientMaterialClaim | insufficient | | fifty-move | — | 50move | | timeout-vs-insufficient | — | timevsinsufficient | | variant-end | variantEnd | kingofthehill, threecheck, bughousepartnerlose | | abandoned | — | abandoned (a loss for the abandoning player) | | aborted / cheat / no-start | aborted / cheat / noStart | — | | created / started | created / started | — |

On Lichess a finished game with no winner is a draw — this matters for outoftime, where a flag fall against insufficient material is scored as a draw rather than a loss. Games with no winner and a non-final status (created, started, aborted, cheat, noStart, unknownFinish) get no result; cheat/noStart normally do carry a winner and therefore do get one.

Note that Lichess does not expose why a game was drawn — agreement, threefold, fifty-move and insufficient material all arrive as status: "draw", which maps to the unified draw. Only Chess.com distinguishes them (agreed, repetition, 50move, insufficient).

UnifiedGame

{
  platform, id, url, rated,
  variant, speed, perf, timeControl,
  status, winner?, result?,            // 'white' | 'black' | 'draw'
  players: { white: { username, rating, result, resultCode, accuracy?, analysis? }, black: {...} },
  opening?: { eco?, name?, ply? },
  fen?, initialFen?, moves, pgn?,      // moves = bare SAN on both platforms
  createdAt?, startedAt?, endedAt?,    // real Dates, both epochs handled
  accuracy?: { white?, black? },
  clocks?: number[],                   // centiseconds per ply
  tournament?: { id?, name? },
}

Chess.com omits start_time for live games, so startedAt is recovered from the PGN UTCDate/UTCTime tags, and clocks are parsed out of [%clk ...] comments. Timestamps are never fabricated: if a payload carries none, createdAt is undefined rather than "now".

fen requires Lichess's lastFen parameter, and pgn requires pgnInJsongetGames/getGame request both for you, but raw LichessClient calls do not by default, in which case those fields are simply absent rather than filled with a placeholder.

UnifiedPlayer / UnifiedStats / UnifiedPerf

UnifiedPlayer  { platform, id, username, url, title?, createdAt?, seenAt?,
                 perfs: Record<PerfKey, UnifiedPerf>, counts, playTimeSeconds?, profile? }

UnifiedPerf    { games, rating, rd, prog, prov, lastDate?,
                 best?, highest?, lowest?,        // best = chess.com best win, highest/lowest = lichess
                 record?: {win,loss,draw}, tournament?, opAvg?, rank?, percentile? }

UnifiedStats   { platform, username, perfs, tactics?, lessons?, puzzleRush? }

getPlayer(user, 'chesscom') transparently merges /player + /stats so ratings are populated on both platforms, and aggregates counts from the per-pool records.

Chess.com only publishes rated win/loss/draw records per pool, so for that platform counts.all === counts.rated (unrated games aren't exposed). Lichess reports both independently. UnifiedPerf.best is Chess.com's best win, while highest/lowest are Lichess's all-time extremes — they're deliberately separate fields rather than being conflated.


API

const api = createChessApi({
  userAgent,        // recommended: identify your app + contact
  retries = 2,      // on 429 / 5xx, exponential backoff, honours Retry-After
  retryDelayMs,
  serialize = true, // one request at a time (both APIs ask for this)
  timeoutMs = 30000,
  fetchImpl,        // inject your own fetch (used by the test suite)
});

| Method | Notes | |---|---| | getPlayer(username, platform) | unified profile (+ratings) | | getPlayers(username) | both platforms, missing ones omitted | | getStats(username, platform) | full stats; on Lichess fans out over every played perf | | getGames(username, opts) | merged, newest-first; platforms, since, until (ms), max, perfType, rated, vs, color, onGame, sort, withPgn, withClocks, maxArchives | | getGame({ platform: 'lichess', id }) | single game | | getArchives(username, 'chesscom') | monthly archives, newest first | | getRatingHistory(username) | Lichess, per perf | | getCrosstable(u1, u2) | Lichess head-to-head (lifetime points, not win counts) |

Raw clients: api.lichess (getUser, getPerf, getRatingHistory, getCrosstable, exportGames, exportGame, exportGamesByIds, getTvChannels) and api.chesscom (getPlayer, getStats, getArchives, getMonthlyGames, getGamesByTimeControl, getCurrentGames, getGamesToMove, isOnline, getLeaderboards, getTitledPlayers, getPuzzle, getStreamers).

Errors: ApiError, NotFoundError (404), RateLimitedError (429). getGames treats a 404 as "no games on that platform" (so merging across platforms works for users who only exist on one) but propagates everything else — rate limits, timeouts and malformed payloads are never silently swallowed.

onGame may itself call the API: the per-platform request queue is released before the response body is streamed, so enriching each game as it arrives does not deadlock.

Platform quirks worth knowing

  • Chess.com has no single-game endpoint. Games come from monthly archives, so getGames/getArchives walk months newest-first until max/since is met. Within a month Chess.com lists games oldest-first, so the wrapper iterates each month in reverse — max: 10 really means "the 10 most recent games".
  • Chess.com data can be up to 12–24h stale (their cache policy) and rate-limits only parallel requests; Lichess rate-limits aggressively — keep serialize: true (requests are serialized per host, so the two platforms don't block each other).
  • since/until are milliseconds everywhere in this wrapper (Lichess's convention); they're converted to seconds for Chess.com internally.
  • vs, color and analysed filters only exist on Lichess; they're ignored for Chess.com, which has no server-side filtering beyond month and time control.
  • Chess.com filters since/until on game end, Lichess on game creation (its server-side filter). For daily games those differ by days, so a window can include a game whose createdAt falls outside it.
  • perfType on Chess.com is filtered client-side after each monthly fetch, so a rare pool on an old account means many serialized requests — maxArchives (default 24) bounds that.
  • timeoutMs covers the response body, and acts as an idle timeout for streams. An oversized Retry-After fails fast (retryAfterCapMs, default 60s) instead of parking the whole queue.

Development

npm install
npm run typecheck        # tsc --noEmit
npm test                 # unit tests (no network)
npm run test:integration # live API tests (NETWORK=1)
npm run demo             # node examples/demo.ts thibault erik

Unit tests mock fetch, so the NDJSON streaming, retry/backoff, serialization and archive-pagination logic are all covered deterministically.