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

@ecosyste-ms/ecosystems-ts

v0.2.0

Published

TypeScript client library for the ecosyste.ms APIs

Readme

ecosystems-ts

TypeScript client library for the ecosyste.ms APIs. See the API documentation for details.

A port of ecosystems-go. Two runtime dependencies: openapi-fetch (6 kB) and packageurl-js.

Installation

npm install @ecosyste-ms/ecosystems-ts

Requires Node 20+, or any server-side runtime with fetch (Deno, Bun, Cloudflare Workers).

Not browsers — the API hides the headers pagination depends on from cross-origin scripts, so importing this in a browser build throws. Call it from your server.

Usage

import { EcosystemsClient } from "@ecosyste-ms/ecosystems-ts";

// userAgent is required - identify your application
const client = new EcosystemsClient({ userAgent: "my-app/1.0" });

// Bulk lookup packages by PURL
const { results, failures } = await client.bulkLookup([
  "pkg:gem/rails",
  "pkg:npm/lodash",
  "pkg:pypi/requests",
]);

// Batches that failed, with the purls to retry
console.log(failures.flatMap((f) => f.purls));

for (const [purl, pkg] of results) {
  console.log(`${purl}: ${pkg.name} (${pkg.latest_release_number})`);
}

// Lookup a single package
const pkg = await client.lookup("pkg:gem/rake");
console.log(`rake has ${pkg?.versions_count} versions`);

// Get a specific version
const version = await client.getVersion("rubygems.org", "rake", "13.0.0");
console.log(`rake 13.0.0 integrity: ${version?.integrity}`);

// Get all versions
const versions = await client.getAllVersions("rubygems.org", "rake");
console.log(`rake has ${versions.length} versions`);

// Get just the version numbers - one request, no Version objects
const numbers = await client.getVersionNumbers("rubygems.org", "rake");

// Every critical package across all registries (~9,600 today, ~96 requests)
const critical = await client.listCriticalPackages();

lookup is the single-package counterpart to bulkLookup: same PURL vocabulary, one request, one package. It takes a PURL string; lookupPurl takes a parsed PackageURL (see PURL Helpers).

bulkLookup chunks its input at batchSize and sends the batches one after another, so 5,000 PURLs is 50 round-trips. A failed batch does not fail the call — it lands in failures carrying its purls, so the other batches survive and you can retry the rest. Nothing throws when every batch fails, so check failures.

Lookups that find nothing return null (or [] for lists) rather than throwing — "not found" is a normal outcome for a lookup API, not an exception.

PURL Helpers

import {
  parsePurl,
  purlToRegistry,
  purlToName,
} from "@ecosyste-ms/ecosystems-ts";

// Parse a PURL string (handles with or without the pkg: prefix)
const purl = parsePurl("gem/[email protected]");

// Convert PURL to an ecosyste.ms registry name
purlToRegistry(purl); // "rubygems.org"

// Convert PURL to the ecosyste.ms package name format
purlToName(purl); // "rails"

// Lookup using a PURL directly
const pkg = await client.lookupPurl(purl);
const version = await client.getVersionPurl(purl);
const versions = await client.getAllVersionsPurl(purl);

The API speaks two vocabularies: bulk lookup takes PURLs, but the per-package endpoints are /registries/{registry}/packages/{name} — and a registry name is not a PURL type. These helpers bridge them. The mapping is generated weekly from GET /registries and covers all 51 ecosystems it serves; client.listRegistries() is authoritative at runtime.

purlToRegistry returns null for types with no ecosyste.ms registry (github, generic, oci, rpm, …) rather than a name that 404s. deb resolves by vendor — pkg:deb/ubuntu/curl to the current Ubuntu LTS, pkg:deb/debian/curl to the current Debian stable, anything else to null.

Repository Metadata

const repoUrl = "https://github.com/rails/rails";

const repo = await client.getRepository(repoUrl);
const packages = await client.lookupPackagesByRepositoryUrl(repoUrl, 25);
const advisories = await client.getAdvisoriesByRepoUrl(repoUrl, 100);
const commits = await client.getCommitsSummary(repoUrl);
const issues = await client.getIssuesSummary(repoUrl);
const dependents = await client.getDependentPackages("rubygems.org", "rails", 30);

List methods follow Link: rel="next" pagination and stop at the requested item cap when one is provided. Reaching the page ceiling throws rather than silently returning a short list.

That ceiling (maxPages, default 1000) exists to stop a server looping rel="next" forever, not to bound result size - whole-collection crawls like listCriticalPackages() are expected to run to hundreds of pages. A call given an explicit item cap is not subject to it: the cap already bounds the work, so getDependentPackages(reg, name, 500_000) will not fail at page 1000 for a limit it never set.

Those methods hold every page in memory. To stream instead, client.paginate yields one page at a time and stops fetching the moment you stop consuming:

const pages = client.paginate(() =>
  client.packages.GET("/registries/{registryName}/packages/{packageName}/dependent_packages", {
    params: { path: { registryName: "npmjs.org", packageName: "lodash" } },
  }),
);

for await (const page of pages) {
  for (const pkg of page) console.log(pkg.name);
}

It takes a thunk returning any openapi-fetch result, so it works on the endpoints this facade wraps and on the many it does not. The page ceiling still applies and still throws, so a truncated stream can never be mistaken for a complete one.

Options

const client = new EcosystemsClient({
  userAgent: "my-app/1.0",       // required
  from: "[email protected]",       // From header - see Rate limits below
  apiKey: "your-api-key",        // Authorization: Bearer - see Rate limits below
  batchSize: 50,                 // PURLs per bulk lookup request (max 100)
  timeoutMs: 30_000,             // deadline per HTTP request, including its retries
  maxPages: 1000,                // runaway-pagination guard; see Pagination below
  retry: { attempts: 3 },        // or `false` to disable
  fetch: myFetch,                // custom fetch (proxies, agents, tests)
  servers: {                     // per-service base URL overrides; any of
    packages: "https://custom.packages.server",  // packages, repos, advisories,
  },                                             // commits, issues
});

Every method takes an optional trailing { signal } for cancellation.

const controller = new AbortController();
const pkg = await client.lookup("pkg:gem/rake", { signal: controller.signal });

timeoutMs covers one HTTP request and its retries, so every bulk batch and every followed page gets its own budget. For a deadline over a whole operation — 50 batches of a large bulkLookup, say — pass your own signal:

await client.bulkLookup(purls, { signal: AbortSignal.timeout(120_000) });

Rate limits

ecosyste.ms runs two pools. Identifying yourself triples your quota:

new EcosystemsClient({ userAgent: "my-app/1.0", from: "[email protected]" });

Passing from appends (mailto:[email protected]) to your User-Agent and sets the From header. The User-Agent is what does the work. Measured 2026-08-16, every sample a cf-cache-status: MISS:

| Identification | Tier | Limit | | --- | --- | --- | | nothing | anonymous | 5000/hr | | From: header | anonymous | 5000/hr | | email in the User-Agent | polite | 15000/hr | | ?mailto= query parameter | polite | 15000/hr |

The From header alone does nothing. We send it as the polite HTTP convention, but never rely on it — put the email in the User-Agent, which is what from does for you.

?mailto= works too and is deliberately not used: the CDN sends Vary: Origin only, so a query parameter gives every email its own cache entry and fragments the shared cache. Identifying through the User-Agent costs nothing.

API keys

If you have been issued a key, pass it as apiKey and it is sent as Authorization: Bearer <key> on every request, across all five services:

new EcosystemsClient({ userAgent: "my-app/1.0", apiKey: process.env.ECOSYSTEMS_API_KEY });

Keys are not part of the public ecosyste.ms flow — there is no self-serve signup and the OpenAPI specs do not describe the scheme — so this is here for hosted or sponsor deployments that do issue one, and for self-hosted instances behind a gateway. The tiers measured above are the ones an anonymous or polite caller sees; a key's quota is whatever the issuing deployment sets. Identify through the User-Agent as well either way — it costs nothing and is what earns the polite pool when no key is in play.

Observing the limit

The most recent response's rate-limit headers are available read-only:

client.rateLimit; // { limit, remaining, reset, tier, consumer } | null

Do not build control flow on these. Responses are CDN-cached, so on a cache hit you receive the cached response's rate-limit headers rather than your own. For real backpressure the client already reacts to 429 and Retry-After.

Beyond the wrapped methods

The methods above cover the common cases. The generated clients are exposed for everything else — the specs describe far more, including /versions/lookup (lookup by integrity/sha256/sha1/sha512 hash), /keywords, /registries/{r}/maintainers, all of /topics and /hosts/**, and issue/commit detail endpoints.

const { data, error, response } = await client.packages.GET("/versions/lookup", {
  params: { query: { sha256: "abc123..." } },
});

client.packages, .repos, .advisories, .commits and .issues are typed openapi-fetch clients sharing the same identity, retry and rate-limit handling.

Generated Code

src/generated/ contains types generated from the vendored specs in specs/. To refresh:

npm run sync-specs   # download the latest OpenAPI specs, record sha256 in specs/.lock.json
npm run generate     # regenerate types + server URLs

Both the specs and the generated output are committed, and CI fails if they drift apart. Base URLs are read from each spec's servers[0].url rather than hardcoded.

The generator is not a dependency of this package. npm run generate runs openapi-typescript from a throwaway npx install, pinned to exact versions at the top of scripts/generate.mjs and tracked by Renovate. It brings its own typescript@5, because openapi-typescript emits through the TypeScript compiler API (ts.factory) and the typescript@7 this package compiles with no longer ships one.

Testing

npm test                  # unit tests (loopback HTTP servers, no network)
npm run test:integration  # integration tests (hits the live API)

Set ECOSYSTEMS_FROM to run the integration suite against the polite pool.

License

MIT