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

@truewire/bluesky

v0.1.0

Published

Typed, validated TypeScript client for Bluesky's public API and the Jetstream firehose, generated by Truewire.

Downloads

108

Readme

Bluesky, in TypeScript

A typed, validated client for Bluesky's public AT Protocol API and the Jetstream firehose. Same spec as the Python client, same recordings, same twelve endpoints — the idioms are the only thing that differs.

Every response is validated against the shape the API was recorded sending, not the shape someone remembered.

Install

npm install @truewire/bluesky

The transport is fetch and nothing else, so Node 22 or newer, Deno, Bun and the browser all work unchanged.

The client

Bluesky.new() builds the two transports and the client over them. Nothing connects until you call something.

import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new()

const profile = await client.actor.getProfile({ actor: 'bsky.app' })
console.log(profile.handle, profile.followersCount)

Every option has a default that works, so the common case takes none:

import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new({
  // The XRPC host. Defaults to the public AppView, or to bsky.social when `accessJwt` is
  // given. Point it at `truewire mock` in tests.
  baseUrl: 'https://public.api.bsky.app',
  // The Jetstream instance. Each subscription opens its own connection to it.
  wsUrl: 'wss://jetstream2.us-east.bsky.network/subscribe',
  // Validate responses and pushed events against their declared types. A call's own
  // `validate` overrides this.
  validate: true,
})

await client[Symbol.asyncDispose]()

No credentials are needed: the public AppView answers eleven of the twelve endpoints without one. An accessJwt from com.atproto.server.createSession is accepted and sent as a bearer token, which is what fills the viewer's own state on a profile against bsky.social.

Calling an endpoint

Every method takes one request object whose keys are the wire's own parameter names, and returns the validated response. Optional parameters are optional; required ones are not, and leaving one out is a compile error.

import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new()

const feed = await client.feed.getAuthorFeed({
  actor: 'bsky.app',
  limit: 3,
  filter: 'posts_no_replies', // a Literal of the four values the lexicon lists
})

for (const entry of feed.feed) {
  console.log(entry.post.indexedAt.toISOString(), entry.post.record.text.slice(0, 48))
}

indexedAt is a Date, not a string: the spec declares it as RFC 3339 and the codec parses it. The same is true of createdAt, and of Jetstream's time_us, which arrives as Unix microseconds and comes back as a Date.

Paging

Six endpoints are cursor-paged, and each has a *Paged twin. It is both awaitable and async-iterable: await it to flatten every page, iterate it to take one page at a time.

import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new()

// Every follower, as one array. The walk stops when the API stops sending a cursor.
const everyone = await client.graph.getFollowersPaged({ actor: 'atproto.com' })

// Or a page at a time, which is what you want for a large account.
for await (const page of client.graph.getFollowersPaged({ actor: 'atproto.com' })) {
  console.log(page.length, 'followers in this page')
  break
}

The paged request type is the endpoint's own request without cursor, because the walk owns the cursor.

Escaping the types

validate: false returns the parsed body exactly as it came, typed unknown. That is how you reach a field the spec does not name — a new embed type, an undeclared extension — without waiting for the spec to catch up.

import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new()

const raw: unknown = await client.feed.getPosts(
  { uris: ['at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3juzlwllznd24'] },
  { validate: false },
)

Subscribing to Jetstream

Jetstream is the firehose of every repository commit, identity change and account status change on the network. events() returns a Subscription, and nothing connects until you open or iterate it.

import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new()

// `await using` closes the socket when the block ends, however it ends.
await using events = client.jetstream.events({
  wantedCollections: ['app.bsky.feed.post'],
})

for await (const event of events) {
  if (event.kind !== 'commit' || event.commit === undefined) continue
  console.log(event.time_us.toISOString(), event.did, event.commit.collection)
  break
}

Each subscription owns its connection, because Jetstream has no subscribe frame: the parameters travel in the connection URL, the server pushes from the moment the socket is accepted, and closing the socket is what unsubscribes. Two subscriptions want two different query strings, so they cannot share one connection.

To resume where a previous run stopped, pass the last time_us you saw as the cursor:

import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new()

const since = new Date(Date.now() - 60_000)
await using events = client.jetstream.events({
  wantedCollections: ['app.bsky.feed.post'],
  cursor: since, // declared as Unix microseconds; a Date is rendered to them
})

for await (const event of events) {
  console.log(event.time_us.toISOString())
  break
}

Lifecycle

There are two things to own, and they behave differently.

HTTP owns nothing. The transport is fetch; there is no pool to open or close, so a client you only make calls with never needs disposing.

A subscription owns a socket. The client is AsyncDisposable, so the shortest correct thing is to declare it with await using and stop thinking about it:

import { Bluesky } from '@truewire/bluesky'

await using client = Bluesky.new()

for await (const event of client.jetstream.events({ wantedCollections: ['app.bsky.feed.post'] })) {
  console.log(event.did)
  break
}

await using is TC39 explicit resource management. TypeScript 5.2 and later compile it down to a try/finally against Symbol.asyncDispose, which Node has had since 20.4, so a TypeScript caller needs nothing but a recent compiler. Written in plain JavaScript it is a syntax error until Node 24, which is the first release whose V8 implements it natively. If that is you, the explicit form does the same thing everywhere:

import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new()
try {
  for await (const event of client.jetstream.events({ wantedCollections: ['app.bsky.feed.post'] })) {
    console.log(event.did)
    break
  }
} finally {
  await client[Symbol.asyncDispose]()
}

When the client outlives a block — a long-running process, a server — close the subscription rather than the client:

import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new()

// Scope one subscription: closed at the end of the block, on an exception or an early
// return.
{
  await using events = client.jetstream.events({ wantedCollections: ['app.bsky.feed.post'] })
  for await (const event of events) { void event; break }
}

// Or open it yourself and unsubscribe when done.
const subscription = client.jetstream.events({ wantedCollections: ['app.bsky.feed.post'] })
const stream = await subscription.open()
await stream.unsubscribe()

// Disposing the client closes every socket it still holds, whatever opened them.
await client[Symbol.asyncDispose]()

Breaking out of a for await closes that socket too: the generator's finally runs on an early exit, so neither loop above leaks even without await using.

Errors

A non-2xx XRPC answer becomes a typed error carrying the API's own error and message. 400 is a BadRequest, 401/403 an AuthError, 429 a RateLimited, anything else an ApiError; a response that does not match its declared shape is a ValidationError.

import { isTruewireError, RateLimited } from '@truewire/core'
import { Bluesky } from '@truewire/bluesky'

const client = Bluesky.new()

try {
  await client.identity.resolveHandle({ handle: 'nobody.invalid' })
} catch (e) {
  if (e instanceof RateLimited) console.error('backing off')
  else if (isTruewireError(e)) console.error(e.message)
  else throw e
}

Tests

yarn typecheck
yarn test

The tests replay every recorded example through this client against truewire mock, which serves the same recordings over real HTTP and a real WebSocket. Nothing touches the network, and the assertions are the ones the Python suite makes about the same responses, so the two clients cannot quietly diverge.

Every ```ts block on this page is compiled against the package by test/readme.test.ts.

What is hand-written

truewire generate typescript writes the endpoints, the routers, the types and the codecs. It never writes the transport, which is the part that knows the API's habits:

  • src/bluesky/core/http.ts — the AppView transport and the XRPC error mapping. A list-valued parameter (uris, actors) is sent as repeated query keys, which is the one wire detail that is easy to get wrong and invisible until a real call.
  • src/bluesky/core/jetstream.ts — one socket per subscription, and nothing sent on it.
  • src/bluesky/core/client.tsBluesky.new() and disposal, on a subclass of the generated client. Generated TypeScript imports nothing from this package, so the two things a generated class cannot carry — a factory and a lifecycle — arrive by subclassing it here (NOTES.md #13).