yasuo.js
v0.3.2
Published
The modern, fully-typed, zero-dependency Riot Games API client for TypeScript. The evolution of Twisted, with a proactive rate limiter, lazy relations and async pagination.
Maintainers
Readme
Yasuo.js
A modern, zero-dependency TypeScript client for the Riot Games API — complete coverage of League of Legends, Teamfight Tactics, VALORANT, Legends of Runeterra, Tournament and the Riot Account API.
Yasuo is the evolution of twisted. It keeps everything that made twisted pleasant — a single client, typed responses, rate-limit info attached to every result — and rebuilds it around a Supabase-style query builder, lazy relation-aware chaining, a pluggable cache, a leveled logger and async iterators, all with no runtime dependencies. It wraps 100% of the key-authenticated Riot API surface, and lets you sign each product with its own API key.
🎮 Live demo — www.yasuo.gg
A League of Legends summoner-profile site — profiles, ranked, match history, champion mastery and a public JSON API — built entirely on this library, running on Cloudflare Workers. Live proof yasuo works in a real app.
import { Yasuo, Region, RegionGroup } from 'yasuo.js'
const yasuo = new Yasuo(process.env.RIOT_API_KEY)
// Every call is a query you run with .execute(). The result IS the entity —
// it carries its own `.error` and `.http`, and never throws for an API failure:
const account = await yasuo.riot.account
.byRiotId('Hide on bush', 'KR1', RegionGroup.ASIA)
.execute()
// Walk relations off the entity — this fetches ONLY the match list, not the summoner:
const matches = await account.summoner(Region.KR).matchIds({ count: 5 }).execute()
console.log(matches) // Collection<string> — ['KR_1234…', …]
console.log(matches.error) // null on success, the ApiError on failure
console.log(matches.http.rateLimits.app) // rate-limit budget travels with every result📚 Full documentation: https://docs.yasuo.gg/
Why yasuo?
| | twisted | leaguejs | yasuo |
| --- | --- | --- | --- |
| Runtime dependencies | a few | 8 (request, bluebird, …) | zero |
| TypeScript | typed | none shipped (plain JS) | first-class, fully typed |
| Response shape | { response, rateLimits } envelope | raw DTO, rejects on API errors | the entity itself, carrying .error + .http — never throws for API errors |
| Requests | eager method calls | eager promise methods | query builders — .byPuuid(…) builds, .execute() runs |
| Chaining / relations | manual (fetch summoner → fetch matches) | manual | account.summoner(r).matchIds().execute() — only the final request runs |
| Rate limiting | reactive (retry on 429) | proactive (SPREAD), built-in | reactive by default, opt-in proactive pacing under Riot's limits |
| Transport | fixed | fixed (deprecated request) | pluggable HttpClient + stackable axios-style middleware (global & per-service) |
| Pagination | manual page loops | manual | async iterators (for await), start from any page |
| Caching | — | in-memory (node-cache) | pluggable in-memory / Redis cache |
| Logging | — | — | leveled logger (debug/info/warn/error), env-driven |
| Magic strings | some | strings ('euw1', …) | none — everything is an enum |
| Riot API coverage | LoL · TFT · Account | LoL only | LoL · TFT · VALORANT · LoR · Tournament · Account — 100% of the key-auth surface |
| API keys | one key | one key | one key, or one per product (Riot's recommendation) — routed automatically |
| Module format | CJS | CJS | dual ESM + CJS, single-file |
Install
bun add yasuo.js
# or
npm install yasuo.jsyasuo targets Node 18+ / Bun / Deno and ships a single-file dual ESM + CJS build with complete type declarations.
Quick start
import { Yasuo, Region, RegionGroup } from 'yasuo.js'
// The key can be passed directly or read from the RIOT_API_KEY env var.
const yasuo = new Yasuo({ key: process.env.RIOT_API_KEY })
// Game-scoped namespaces mirror Riot's own product split. Each method returns a
// query; run it with .execute() and use the entity directly:
const summoner = await yasuo.lol.summoner.byPuuid(puuid, Region.KR).execute()
const entries = await yasuo.lol.league.byPuuid(puuid, Region.KR).execute()
const match = await yasuo.lol.match.get('KR_1234567890', RegionGroup.ASIA).execute()
const tftMatch = await yasuo.tft.match.get('KR_1234567890', RegionGroup.ASIA).execute()
const account = await yasuo.riot.account.byRiotId('Faker', 'KR1', RegionGroup.ASIA).execute()Core concepts
Namespaces
The client is organised by product, matching Riot's routing model:
yasuo.lol.*— Summoner, League, Champion Mastery, Champion, Match, Spectator, Status, Clash, Challenges, Tournament (+ Tournament Stub)yasuo.tft.*— Summoner, League, Match, Spectatoryasuo.val.*— Content, Match, Console Match, Ranked, Status (routes byShard)yasuo.lor.*— Match, Ranked, Statusyasuo.riot.account— the shared Account API (game name / tag line → PUUID)yasuo.dataDragon— Riot's static data CDN (no key, no rate limits)
Per-product API keys
Riot recommends registering a separate product — and key — per game. Pass a keys map and yasuo signs every request with the key for that request's product, automatically; distinct keys get independent rate-limit budgets. Anything omitted falls back to the shared key, then to environment variables.
const yasuo = new Yasuo({
keys: {
lol: process.env.RIOT_LOL_KEY,
tft: process.env.RIOT_TFT_KEY,
val: process.env.RIOT_VAL_KEY,
lor: process.env.RIOT_LOR_KEY,
},
key: process.env.RIOT_API_KEY, // shared fallback (e.g. for the Account API)
})
await yasuo.val.content.get(Shard.NA).execute() // signed with the VAL key
await yasuo.lol.summoner.byPuuid(puuid, Region.KR).execute() // signed with the LoL keyResolution order per request: keys[game] → RIOT_<GAME>_API_KEY env → shared key → RIOT_API_KEY env. The Account API borrows any configured product key when it has none of its own. A single key (or new Yasuo(process.env.RIOT_API_KEY)) still works exactly as before.
VALORANT, Legends of Runeterra & Tournaments
import { Yasuo, Shard, RegionGroup, ValQueue } from 'yasuo.js'
// VALORANT routes by Shard; a matchlist is relation-aware.
const list = await yasuo.val.match.matchlist(puuid, Shard.NA).execute()
const match = await list.match(list.matchIds()[0]).execute()
const board = await yasuo.val.ranked.leaderboard(actId, Shard.NA, { size: 10 }).execute()
// Legends of Runeterra routes by RegionGroup.
const lorMatches = await yasuo.lor.match.byPuuid(puuid, RegionGroup.AMERICAS).execute()
// Tournament-V5 (POST flow) — or `tournamentStub` with no production key:
const provider = await yasuo.lol.tournamentStub
.registerProvider({ region: 'NA', url: 'https://cb.example' }, RegionGroup.AMERICAS)
.execute()Query builders + the result model
Every method returns a query builder — nothing hits the network until you call the terminal .execute(). It resolves to the entity (or collection) directly, and that entity carries its own error and HTTP context. A request never throws for an API-level failure: on failure the DTO fields are absent, .error holds the original rich ApiError and .http.ok is false; on success .error is null.
const summoner = await yasuo.lol.summoner.byPuuid(puuid, Region.KR).execute()
summoner.summonerLevel // typed DTO field, right on the entity
summoner.error // ApiError | null (the original failure)
summoner.http.ok // boolean
summoner.http.status // 200
summoner.http.rateLimits.app // [{ limit, intervalSeconds, count }]
summoner.http.headers // raw, lower-cased response headers
// Idiomatic: branch on `.error`.
if (summoner.error) return console.error(summoner.error.status, summoner.error.body)
console.log(summoner.summonerLevel)
// Prefer exceptions? Opt in per call:
const strict = await yasuo.lol.summoner.byPuuid(puuid, Region.KR).execute({ throw: true })
// Want exactly what Riot returned? Pass { raw: true } — typed `unknown` by
// default, or supply the shape you expect as a type argument:
const raw = await yasuo.lol.summoner.byPuuid(puuid, Region.KR).execute({ raw: true })
const dto = await yasuo.lol.summoner
.byPuuid(puuid, Region.KR)
.execute<{ puuid: string; summonerLevel: number }>({ raw: true }) // typed, no castScalar endpoints (e.g. a mastery score) can't hang metadata off a primitive, so they resolve a small ValueResult<T> — read the number from .value, with the same .error/.http:
const score = await yasuo.lol.mastery.score(puuid, Region.KR).execute()
console.log(score.value) // number | nullLazy relations — the key idea
byPuuid(...) returns a lazy reference that is itself a query (.execute() fetches the summoner). Calling a relation returns another query that fetches only that related resource — the summoner request is skipped entirely:
// One request (the match list) — the summoner is never fetched:
const ids = await yasuo.lol.summoner.byPuuid(puuid, Region.KR).matchIds({ count: 20 }).execute()
// Running the ref itself fetches the summoner:
const summoner = await yasuo.lol.summoner.byPuuid(puuid, Region.KR).execute()Relations know how to route themselves. A summoner on Region.KR traverses to their match history on RegionGroup.ASIA automatically — you never re-specify the routing:
const account = await yasuo.riot.account.byRiotId('Hide on bush', 'KR1', RegionGroup.ASIA).execute()
const ref = account.summoner(Region.KR)
const masteries = await ref.topChampionMasteries(5).execute()
const live = await ref.activeGame().execute() // CurrentGameEntity | null
const match = await yasuo.lol.match.get(ids[0], RegionGroup.ASIA).execute()
const timeline = await match.timeline().execute() // ids reused, region derivedNo magic strings
Regions, queues, tiers, divisions, match types, challenge levels, HTTP details — all enums:
import { Region, RegionGroup, RankedQueue, Tier, Division, MatchType } from 'yasuo.js'
// Async iterators throw on failure, which is idiomatic for `for await`:
for await (const entry of yasuo.lol.league.streamEntries(
RankedQueue.SOLO_5x5, Tier.DIAMOND, Division.I, Region.EUW, { startPage: 3 },
)) {
console.log(entry.puuid, entry.leaguePoints)
}Rate limiting
Reactive retries are always on: a 429/503 honours retry-after with bounded backoff. Proactive pacing is opt-in (rateLimit: true) — enable it and the limiter reads Riot's x-app-rate-limit / x-method-rate-limit headers and paces requests underneath the advertised limits using sliding windows, per application host and per method, so you avoid 429s before they happen.
const yasuo = new Yasuo({
key,
rateLimit: true, // opt in to proactive pacing (off by default)
retry: { maxAttempts: 3 } // reactive retry policy (on by default)
})Caching
Opt-in response caching, served before the rate limiter is even consulted. In-memory by default; bring your own store — Redis or Cloudflare KV need no dependency (yasuo just wants a client that matches RedisClientLike / KVNamespaceLike), or implement the CacheStore interface for anything else:
import { Yasuo, RedisCache, KVCache } from 'yasuo.js'
const yasuo = new Yasuo({ key, cache: true }) // in-memory, 60s TTL
const y2 = new Yasuo({ key, cache: { store: new RedisCache(redis), ttlMs: 30_000 } })
const y3 = new Yasuo({ key, cache: { store: new KVCache(env.RIOT_CACHE) } }) // Cloudflare Workers KV
// Or pass a raw client/namespace and yasuo wraps it (redis-like -> RedisCache, kv-like -> KVCache):
const y4 = new Yasuo({ key, cache: { store: redis } })
const y5 = new Yasuo({ key, cache: { store: env.RIOT_CACHE } })See the caching guide.
Custom transport & middleware
Inject any HttpClient (a single send(request) method) — a proxy, an undici pool, a mock in tests — and stack axios-style middleware. Middleware runs in an onion: global middleware (yasuo.use(...) or the middleware config) wraps per-service middleware (yasuo.lol.summoner.use(...)), which wraps the transport. Each can inspect or rewrite the request and response, short-circuit, or retry.
import { Yasuo, type HttpMiddleware } from 'yasuo.js'
const timing: HttpMiddleware = async (request, next, { endpointId }) => {
const started = performance.now()
const response = await next(request)
console.log(`${endpointId} → ${response.status} in ${(performance.now() - started).toFixed(0)}ms`)
return response
}
const yasuo = new Yasuo({ key, httpClient: myClient, middleware: [timing] })
// Add more at runtime — global, or scoped to one service:
yasuo.use((request, next) => next({ ...request, headers: { ...request.headers, 'x-app': 'my-bot' } }))
yasuo.lol.match.use((request, next) => { console.debug('match', request.url); return next(request) })See the transport & middleware guide.
Logging
A leveled logger driven by config or the YASUO_LOG_LEVEL / LOG_LEVEL env var (debug · info · warn · error · silent):
import { Yasuo, LogLevel } from 'yasuo.js'
const yasuo = new Yasuo({ key, logLevel: LogLevel.DEBUG }) // or set YASUO_LOG_LEVEL=debugSee the logging guide.
Pagination & async iterators
Paginated endpoints return a Paginator you can iterate with for await, collect with .toArray(), peek with .first(), or walk page-by-page with .pages() — starting from any page/offset. Iterators throw on failure (they use { throw: true } internally), which is the natural contract for for await:
for await (const id of yasuo.lol.match.streamIds(puuid, RegionGroup.ASIA, { start: 0, pageSize: 100 })) {
// …every match in a player's history, paced by the rate limiter
}
// Or collect a bounded slice eagerly:
const firstFifty = await yasuo.lol.match.streamIds(puuid, RegionGroup.ASIA).toArray(50)See the pagination guide.
Error handling
A request never throws for an API error — inspect the entity's .error, a typed subclass of ApiError (itself a YasuoError) that carries the original HTTP response:
import { NotFoundError, RateLimitError, ForbiddenError } from 'yasuo.js'
const summoner = await yasuo.lol.summoner.byPuuid(puuid, Region.KR).execute()
if (summoner.error instanceof NotFoundError) { /* 404 */ }
else if (summoner.error instanceof RateLimitError) { /* 429 — error.rateLimits.retryAfterSeconds */ }
else { console.log(summoner.summonerLevel) }Prefer exceptions? .execute({ throw: true }) throws that same ApiError instead of attaching it. Misuse (a missing/invalid key) always throws, regardless.
API coverage
100% of the key-authenticated Riot Games API — every product, every endpoint a Riot API key can call. 74 endpoints:
- LoL (44): Summoner · League (+ league-exp) · Champion Mastery · Champion Rotation · Match-V5 · Spectator · Status · Clash · Challenges · Tournament-V5 · Tournament-Stub-V5
- TFT (14): Summoner · League (+ rated ladder) · Match · Spectator
- VALORANT (8): Content · Match · Console Match · Ranked · Status
- Legends of Runeterra (4): Match · Ranked · Status
- Riot (4): Account (by PUUID / by Riot ID) · Active Shard · Active Region
- Data Dragon: versions, champions, runes, static reference data
The only Riot endpoints not wrapped are the RSO/OAuth-gated ones (LoR Deck & Inventory, RSO Match), which require a user-authorization flow rather than an API key.
See the endpoint map for the full list.
Documentation
Full docs live at https://docs.yasuo.gg/:
- Getting started
- Architecture & contribution rules
- Entities & lazy relations
- Rate limiting
- Caching
- Logging
- Transport & middleware
- Pagination
- Errors
- Endpoint coverage
- Migrating from twisted
Showcase
- yasuo.gg — a complete League of Legends summoner-profile site and public JSON API, built entirely on yasuo and running on Cloudflare Workers. A real, end-to-end demonstration that the library works.
Migrating from twisted
yasuo is a spiritual successor, not a drop-in replacement — the ergonomics are better. The short version: constructor takes a config object, LolApi/TftApi/RiotApi become yasuo.lol/yasuo.tft/yasuo.riot, the { response, rateLimits } envelope becomes the entity itself (carrying .error/.http) that you run with .execute(), and encrypted-summoner-id lookups give way to PUUIDs. Full mapping in the migration guide.
Development
bun install
bun run typecheck # tsc --noEmit
bun run lint # biome check .
bun run test:unit # unit tests (no network) — coverage-gated at 95%
bun run test:integration # live tests (needs RIOT_API_KEY)
bun run build # single-file ESM + CJS + d.ts
bun run docs:serve # preview the MkDocs site locallyUnit tests run network-free (inject MockHttpClient or a fake HttpClient/fetch) and are coverage-gated at 95% line/statement coverage (currently ~98%) via bunfig.toml. Conventions for contributors (folder layout, one-declaration-per-file, enum rules) live in the architecture guide.
License
MIT © justadev-afk
