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

otsuapi

v0.1.1

Published

A typed, zero-dependency osu! API v2 client for Node.js

Readme

otsu

A typed, zero-dependency osu! API v2 client for Node.js 22+.

Requirements

Install

npm install otsuapi

Quick start

import { createOsuClient, clientCredentials, Ruleset } from "otsuapi";

const client = createOsuClient({
  auth: clientCredentials({
    clientId: Number(process.env.OSU_CLIENT_ID),
    clientSecret: process.env.OSU_CLIENT_SECRET!,
    scopes: ["public"],
  }),
});

const user = await client.users.get("@peppy", { mode: Ruleset.Osu });
const scores = await client.users.getScores(user.id, {
  type: "best",
  mode: Ruleset.Osu,
  limit: 10,
});

Set credentials in your environment:

# PowerShell
$env:OSU_CLIENT_ID = "your_client_id"
$env:OSU_CLIENT_SECRET = "your_client_secret"

Authentication

Client credentials (scripts & bots)

import { clientCredentials } from "otsuapi";

const auth = clientCredentials({
  clientId: 12345,
  clientSecret: "your_secret",
  scopes: ["public"],
});

Tokens are fetched automatically and refreshed before expiry. Concurrent requests share a single refresh.

Static token (tests & manual OAuth)

When you already have an access token:

import { staticToken } from "otsuapi";

const auth = staticToken(process.env.OSU_ACCESS_TOKEN!);

Authorization code (user-facing apps)

For endpoints that need a logged-in user (/me, friends, replays, etc.).

1. Register a callback URL in your OAuth app settings (e.g. http://127.0.0.1:3914/callback for local scripts).

2. Redirect the user to osu! for consent:

import { createOsuClient, authorizationCode, fileTokenStore, generateOAuthState } from "otsuapi";

const auth = authorizationCode({
  clientId: 12345,
  clientSecret: "your_secret",
  redirectUri: "https://your-app.com/callback",
  scopes: ["public", "identify", "friends.read"],
  store: fileTokenStore(".osu-tokens.json"),
});

const url = auth.getAuthorizeUrl({ state: generateOAuthState() });
// redirect the user to `url`

3. Exchange the callback code for tokens:

await auth.exchangeCode(codeFromCallback);

4. Use the client — tokens refresh automatically:

const client = createOsuClient({ auth });
const me = await client.me.get();

Local CLI login

For scripts, use the built-in callback server:

import { createAuthCallbackServer } from "otsuapi";

const callback = await createAuthCallbackServer({ port: 3914 });
const auth = authorizationCode({
  clientId: 12345,
  clientSecret: "your_secret",
  redirectUri: callback.redirectUri,
  scopes: ["public", "identify"],
  store: fileTokenStore(".osu-tokens.json"),
});

console.log(auth.getAuthorizeUrl());
const code = await callback.waitForCode();
await auth.exchangeCode(code);
await callback.close();

Or run: npm run example:auth

Auth utilities are also available from the otsu/auth subpath.

Client options

const client = createOsuClient({
  auth,
  apiVersion: "20220705", // x-api-version header
  acceptLanguage: "en", // Accept-Language header
  debug: true, // log requests
  logger: (msg) => console.log(msg), // custom debug logger
  rateLimit: {
    requestsPerMinute: 60, // osu! documented limit
    minIntervalMs: 1000, // default: 1 req/s
    onThrottled: (ms) => console.warn(`waiting ${ms}ms`),
  },
  retry: {
    maxAttempts: 3,
    baseDelayMs: 500,
  },
});

API reference

User ids and @usernames are handled automatically. Numeric strings like "12345" are treated as ids.

Scopes noted below are required for authorization-code flows. Client-credentials (public scope) covers most read endpoints.

client.users

| Method | Endpoint | Notes | | -------------------------------------- | ------------------------------------------- | ------------------------------------- | | get(user, options?) | GET /users/{user} | By id or @username; optional mode | | getMany(ids, options?) | GET /users | Bulk fetch by ids | | lookup(ids, options?) | GET /users/lookup | Lookup with optional ruleset | | getScores(user, options?) | GET /users/{user}/scores/{type} | type: best, firsts, recent | | getBeatmapsets(user, type, options?) | GET /users/{user}/beatmapsets/{type} | favourite, ranked, loved, etc. | | getMostPlayed(user, options?) | GET /users/{user}/beatmapsets/most_played | | | getRecentActivity(user, options?) | GET /users/{user}/recent_activity | Returns RecentActivityEvent[] | | getPassedBeatmaps(user, options) | GET /users/{user}/beatmaps-passed | Requires beatmapsetIds | | getKudosuHistory(user, options?) | GET /users/{user}/kudosu | | | getFriends() | GET /friends | Scope: friends.read |

client.beatmaps

| Method | Endpoint | | ---------------------------------------------- | ------------------------------------------------- | | get(beatmapId) | GET /beatmaps/{beatmap} | | getMany(ids) | GET /beatmaps | | lookup(options) | GET /beatmaps/lookup | | getScores(beatmapId, options?) | GET /beatmaps/{beatmap}/scores | | getUserScore(beatmapId, user, options?) | GET /beatmaps/{beatmap}/scores/users/{user} | | getUserScores(beatmapId, user, options?) | GET /beatmaps/{beatmap}/scores/users/{user}/all | | getDifficultyAttributes(beatmapId, options?) | POST /beatmaps/{beatmap}/attributes | | getUserTags() | GET /tags | | getPacks(type?, cursor_string?) | GET /beatmaps/packs | | getPack(packTag, legacy_only?) | GET /beatmaps/packs/{pack} |

client.beatmapsets

| Method | Endpoint | | ------------------------------ | ------------------------------------ | | search(options?) | GET /beatmapsets/search | | searchAll(options?) | Auto-paginates via cursor_string | | get(beatmapsetId) | GET /beatmapsets/{beatmapset} | | lookup(beatmapId) | GET /beatmapsets/lookup | | getDiscussions(options?) | GET /beatmapsets/discussions | | getDiscussionPosts(options?) | GET /beatmapsets/discussions/posts | | getDiscussionVotes(options?) | GET /beatmapsets/discussions/votes | | getEvents(options?) | GET /beatmapsets/events |

Throws OsuResponseError if search returns HTTP 200 with an error field.

client.scores

| Method | Endpoint | Notes | | -------------------- | ------------------------------ | ----------------- | | get(scoreId) | GET /scores/{score} | | | getMany(options?) | GET /scores | Global score feed | | getReplay(scoreId) | GET /scores/{score}/download | Scope: delegate |

client.rankings

| Method | Endpoint | | ------------------------------ | --------------------------------------- | | get(mode, type, options?) | GET /rankings/{mode}/{type} | | getAll(mode, type, options?) | Auto-paginates performance/score/charts | | getKudosu(options?) | GET /rankings/kudosu |

Types: performance, score, country, charts.

client.me

| Method | Endpoint | Scopes | | ----------------------------- | ------------------------------- | ---------- | | get(options?) | GET /me/{mode?} | identify | | getFavouriteBeatmapsetIds() | GET /me/beatmapset-favourites | identify |

client.home

| Method | Endpoint | | --------------------------- | --------------------------------------- | | search(options) | GET /search | | searchUsers(query, page?) | GET /search?mode=user | | searchWiki(query, page?) | GET /search?mode=wiki_page | | searchUsersAll(options) | Paginated user search (max 100 results) | | searchWikiAll(options) | Paginated wiki search |

client.wiki

| Method | Endpoint | | -------------------- | --------------------------- | | get(path, locale?) | GET /wiki/{locale}/{path} |

client.news

| Method | Endpoint | | ------------- | ----------------------------------- | | get(post) | GET /news/{slug} or by numeric id | | list(year?) | GET /news |

client.events

| Method | Endpoint | | --------------- | ------------- | | get(options?) | GET /events |

client.comments

| Method | Endpoint | | ---------------- | ------------------------- | | get(commentId) | GET /comments/{comment} | | list(options?) | GET /comments |

client.changelog

| Method | Endpoint | | ----------------------------- | --------------------------------- | ------- | | getStreams() | GET /changelog | | getBuilds(options?) | GET /changelog | | getBuild(stream, build) | GET /changelog/{stream}/{build} | | lookup(changelog, formats?) | GET /changelog/{id | build} |

client.matches

| Method | Endpoint | | ------------------------ | ---------------------- | | get(matchId, options?) | GET /matches/{match} | | list(options?) | GET /matches |

client.multiplayer

| Method | Endpoint | | ------------------------------------------------- | ------------------------------------------ | | list(type, mode, options?) | GET /rooms | | get(roomId) | GET /rooms/{room} | | getLeaderboard(roomId) | GET /rooms/{room}/leaderboard | | getEvents(roomId) | GET /rooms/{room}/events | | getPlaylistItemScores(roomId, itemId, options?) | GET /rooms/{room}/playlist/{item}/scores |

type: playlists realtime. mode: active, all, ended, participated, owned.

client.forum

| Method | Endpoint | Notes | | ------------------------------------------ | ----------------------------------- | -------------------- | | list() | GET /forums | | | get(forumId) | GET /forums/{forum} | | | listTopics(options?) | GET /forums/topics | | | getTopic(topicId, options?) | GET /forums/topics/{topic} | | | createTopic(forumId, title, text, poll?) | POST /forums/topics | Scope: forum.write | | reply(topicId, body) | POST /forums/topics/{topic}/reply | Scope: forum.write | | editTopicTitle(topicId, title) | PUT /forums/topics/{topic} | Scope: forum.write | | editPost(postId, body) | PUT /forums/posts/{post} | Scope: forum.write | | lockTopic(topicId, lock?) | POST /forums/topics/{topic}/lock | Scope: forum.write | | pinTopic(topicId, pin) | POST /forums/topics/{topic}/pin | Scope: forum.write |

client.chat

| Method | Endpoint | Notes | | ----------------------------------------------------- | ----------------------------------------------------- | -------------------------- | | keepAlive(options?) | POST /chat/ack | Scope: chat.write | | listChannels() | GET /chat/channels | Scope: chat.read | | getChannel(channelId) | GET /chat/channels/{channel} | Scope: chat.read | | markAsRead(channelId, messageId) | PUT /chat/channels/{channel}/mark-as-read/{message} | Scope: chat.write | | createPrivateChannel(targetUserId) | POST /chat/channels | Scope: chat.write_manage | | createAnnouncement(body) | POST /chat/channels | Scope: chat.write_manage | | joinChannel(channelId, userId) | PUT /chat/channels/{channel}/users/{user} | Scope: chat.write_manage | | leaveChannel(channelId, userId) | DELETE /chat/channels/{channel}/users/{user} | Scope: chat.write_manage | | getMessages(channelId, options?) | GET /chat/channels/{channel}/messages | Scope: chat.read | | sendMessage(channelId, message, isAction?) | POST /chat/channels/{channel}/messages | Scope: chat.write | | sendPrivateMessage(targetUserId, message, options?) | POST /chat/new | Scope: chat.write |

WebSocket helpers: createChatWebsocket, createChatWebsocketFromClient, CHAT_WEBSOCKET_URL.

client.teams

| Method | Endpoint | | ------------------ | ------------------- | | get(team, mode?) | GET /teams/{team} |

client.spotlights

| Method | Endpoint | | ---------------------------------------- | ----------------------------- | | list() | GET /spotlights | | getRanking(mode, spotlightId, filter?) | GET /rankings/{mode}/charts |

client.misc

| Method | Endpoint | | -------------------------- | --------------------------- | | getSeasonalBackgrounds() | GET /seasonal-backgrounds |

Type narrowing

Discriminated unions and guards help narrow events and scores at runtime:

import { isEventType, isAchievementEvent, isSoloScore, isMultiplayerScore } from "otsuapi";

const events = await client.events.get();
for (const event of events.events) {
  if (isAchievementEvent(event)) {
    console.log(event.achievement.name);
  } else if (isEventType(event, "rank")) {
    console.log(event.beatmap.title);
  }
}

const scores = await client.users.getScores(2, { type: "best", limit: 10 });
for (const score of scores) {
  if (isMultiplayerScore(score)) {
    console.log(score.room_id);
  } else if (isSoloScore(score)) {
    console.log(score.ranked);
  }
}

Escape hatch

await client.request("GET", "/wiki/{path}", {
  query: { locale: "en" },
});

Pagination

Three pagination styles are used across the osu! API:

| Style | Used by | Helper | | -------------------- | ----------------- | -------------------------------- | | cursor_string | Beatmapset search | client.beatmapsets.searchAll() | | JSON cursor object | Rankings | client.rankings.getAll() | | page number | Home search | client.home.searchUsersAll() |

Low-level helpers are exported if you need them:

import { paginateCursor, paginateJsonCursor, paginatePage } from "otsuapi";

All iterators guard against duplicate cursors and unbounded loops (default max 100 pages).

Error handling

Import error classes from the main package or the otsu/errors subpath:

import {
  OsuNotFoundError,
  OsuRateLimitError,
  OsuAuthenticationError,
  OsuResponseError,
} from "otsuapi";

try {
  await client.users.get(999999999);
} catch (error) {
  if (error instanceof OsuNotFoundError) {
    // 404
  } else if (error instanceof OsuRateLimitError) {
    await sleep(error.retryAfterMs ?? 60_000);
  } else if (error instanceof OsuAuthenticationError) {
    // bad credentials or expired token
  }
}

| Class | When | | ------------------------ | ----------------------------------- | | OsuAuthenticationError | HTTP 401, OAuth failures | | OsuForbiddenError | HTTP 403 (missing scope) | | OsuNotFoundError | HTTP 404 | | OsuValidationError | HTTP 422 | | OsuRateLimitError | HTTP 429 — check retryAfterMs | | OsuResponseError | HTTP 200 with logical error payload | | OsuApiError | Other HTTP errors |

All errors expose status, request, and response for debugging.

Examples

Runnable examples are in examples/ (require OSU_CLIENT_ID and OSU_CLIENT_SECRET):

npm run example              # fetch a user + scores
npm run example:search -- "freedom dive"
npm run example:rankings -- US
npm run example:home -- peppy
npm run example:auth           # interactive OAuth login

Development

npm install
npm test                    # unit tests (mocked fetch) — runs in CI
npm run test:guest          # live API, client credentials (public scope)
npm run test:authenticated  # live API, user token (scoped endpoints)
npm run test:all            # everything
npm run build
npm run typecheck
npm run format              # format with Prettier
npm run format:check        # CI check

Test layout

Tests mirror the osu-api-v2-js structure:

| Directory | Purpose | Credentials | | ---------------------- | -------------------------------------------------- | ------------------------------------ | | tests/unit/ | Mocked tests (client, auth, pagination, resources) | None | | tests/guest/ | Live API with client credentials | OSU_CLIENT_ID, OSU_CLIENT_SECRET | | tests/authenticated/ | Live API with user OAuth token | OSU_ACCESS_TOKEN |

Copy .env.example to .env for local guest/authenticated runs. CI runs unit tests only; guest and authenticated suites skip when credentials are unset.

# PowerShell — guest (public) tests
$env:OSU_CLIENT_ID = "your_client_id"
$env:OSU_CLIENT_SECRET = "your_client_secret"
npm run test:guest

# Authenticated tests (after `npm run example:auth`)
$env:OSU_ACCESS_TOKEN = "your_access_token"
npm run test:authenticated

Types are partial — model interfaces cover documented fields; the API may return more. Use type guards (isSoloScore, isEventType, etc.) for runtime narrowing

License

otsu is under the MIT License. See LICENSE.