メインコンテンツまでスキップ

Jetstream SDK

Jetstream is plain JSON over a WebSocket, so you never need an SDK; some of our examples use CLI tools like websocat. But you'll tend to write the same glue each time: filtering, reconnecting, deduping, and decoding events into typed records. For that, we provide a Jetstream SDK in TypeScript and in Go. You construct a client, pass a filter, and loop over decoded events; it handles the rest.

The SDKs provide a single client for all three ways to consume Jetstream, behind the same loop: the live tail, replay, and snapshotting.

Install

npm install @bsky/jetstream

The TypeScript SDK targets Node 22.15+, is ESM-only, and runs in the browser too. On current Node the TypeScript examples below run as-is (node index.ts), with no build step.

Quickstart: the live tail

Construct a Jetstream client pointed at an instance, then read events with a server-side collection filter. Each value is a decoded event; the record is already parsed, so there's no JSON step of your own:

import { Jetstream } from '@bsky/jetstream'

const jetstream = new Jetstream('https://jetstream.us-east.bsky.network')

for await (const evt of jetstream.live({ collections: ['app.bsky.feed.post'] })) {
if (evt.kind === 'commit' && evt.commit.operation === 'create') {
console.log(evt.commit.collection, evt.commit.record)
}
}

Filtering

Both clients take the same two server-side filters as the raw endpoint, so a narrow filter means less bandwidth and less work in your process:

  • collections: NSIDs, or a app.bsky.feed.*-style namespace wildcard. The server routes events by collection before they reach you.
  • DIDs: restrict the stream to specific accounts.
for await (const evt of jetstream.live({
collections: ['app.bsky.feed.post', 'app.bsky.feed.like'],
dids: ['did:plc:ragtjsm2j2vknwkz3zp4oxrd'],
})) {
// ...only posts and likes, only from that account
}

TypeScript also takes a kinds filter, to get a commits-only stream:

jetstream.live({ collections: ['app.bsky.feed.post'], kinds: ['commit'] })

A collection filter constrains commits only: identity, account, and sync events bypass it and arrive regardless (a DID filter still applies to them). Those events are how a consumer learns an account was deleted and its records should go with it. See folding the stream.

Reacting to events

Every event carries a DID, a seq (the monotonically increasing cursor), a timestamp, and a kind of commit, identity, account, or sync. Exactly one payload is populated, selected by the kind:

for await (const evt of jetstream.live({ collections: ['app.bsky.feed.post'] })) {
switch (evt.kind) {
case 'commit':
if (evt.commit.operation === 'delete') {
console.log('deleted', evt.commit.rkey)
} else {
console.log('put', evt.commit.rkey, evt.commit.record)
}
break
case 'identity':
console.log('handle change', evt.did, evt.identity.handle)
break
...
}
}

You can combine the Jetstream SDK with Lexicons from the Bluesky SDK to validate and type records:

import { Jetstream } from '@bsky/jetstream'
import { app } from '@bsky/sdk/lexicons'

const jetstream = new Jetstream('https://jetstream.us-east.bsky.network')

for await (const evt of jetstream.live({ collections: [app.bsky.feed.post] })) {
if (evt.kind === 'commit' && evt.commit.operation === 'create') {
console.log(evt.commit.collection, evt.commit.record.text)
}
}

Stopping and shutdown

Breaking out of the loop closes the socket for you. For shutdown driven from outside the loop, pass an AbortSignal. Aborting technically rejects the loop with an AbortError, so catch it if a signal is your normal exit path:

const controller = new AbortController()
process.on('SIGINT', () => controller.abort())

try {
for await (const evt of jetstream.live({
collections: ['app.bsky.feed.post'],
signal: controller.signal,
})) {
// ...
}
} catch (err) {
if (controller.signal.aborted) return // expected: we asked it to stop
throw err
}

Resuming where you left off

Each event's seq is a cursor. Both clients reconnect and dedupe within Jetstream's lookback window on their own, so a brief drop needs nothing from you. To resume across a process restart, persist the last seq you handled and hand it back at startup:

Pass a CursorStore. The stream starts from just after the point it loads:

import { Jetstream, type CursorStore } from '@bsky/jetstream'

// Persist `seq` wherever you like — this one is backed by a row in your DB.
const cursor: CursorStore = {
load: async () => db.getCursor(), // number | undefined
save: async (seq) => db.setCursor(seq),
}

const jetstream = new Jetstream('https://jetstream.us-east.bsky.network')

for await (const evt of jetstream.live({ collections: ['app.bsky.feed.post'], cursor })) {
await handle(evt)
await cursor.save(evt.seq) // durably record progress
}

Delivery is at-least-once, so an event can arrive twice across a reconnect. Key your writes on the record's at:// URI (at://{did}/{collection}/{rkey}) and duplicates become harmless.

Indexing workloads

The TypeScript SDK ships LexIndexer and JetstreamRunner. You register a handler per collection and the runner drives the stream: validating records against their lexicon, preserving per-record order under bounded concurrency, and checkpointing the cursor for you.

import { Jetstream, LexIndexer, MemoryCursorStore } from '@bsky/jetstream'
import { app } from '@bsky/sdk/lexicons' // schema-typed collections

const jetstream = new Jetstream('https://jetstream.us-east.bsky.network')

const indexer = new LexIndexer()
.commit(app.bsky.feed.like, {
put: async (e) => console.log('index', e.uri, e.record),
del: async (e) => console.log('remove', e.uri),
})
.identity(async (e) => console.log('identity change', e.did, e.handle))

// Swap MemoryCursorStore for your own CursorStore to persist across restarts.
await jetstream.runner(indexer).live({ cursor: new MemoryCursorStore() })

The runner asks the server only for the kinds the indexer has handlers for, so register .identity(), .account(), or .sync() to receive them. The same runner also drives .replay() and .snapshot().

注記

LexIndexer.commit() keys off a lexicon schema, which is what types and validates the records it hands your put handler. Those schemas ship in the Bluesky SDK. The live tail above needs none of that; plain collection strings are enough.

See also