@atproto/sync
Version:
atproto sync library
420 lines (381 loc) • 11.5 kB
text/typescript
import type { ClientOptions } from 'ws'
import { Deferrable, createDeferrable, wait } from '@atproto/common'
import {
DidDocument,
IdResolver,
parseToAtprotoDocument,
} from '@atproto/identity'
import { Cid } from '@atproto/lex'
import {
RepoVerificationError,
cborToLexRecord,
formatDataKey,
parseDataKey,
readCar,
readCarWithRoot,
verifyProofs,
} from '@atproto/repo'
import { AtUri } from '@atproto/syntax'
import { Subscription } from '@atproto/xrpc-server'
import {
AccountEvt,
AccountStatus,
CommitEvt,
CommitMeta,
Event,
IdentityEvt,
SyncEvt,
} from '../events.js'
import { com } from '../lexicons/index.js'
import { EventRunner } from '../runner/index.js'
import { didAndSeqForEvt } from '../util.js'
export type FirehoseOptions = ClientOptions & {
idResolver: IdResolver
handleEvent: (evt: Event) => void | Promise<void>
onError: (err: Error) => void
getCursor?: () => number | undefined | Promise<number | undefined>
runner?: EventRunner // should only set getCursor *or* runner
service?: string
subscriptionReconnectDelay?: number
unauthenticatedCommits?: boolean
unauthenticatedHandles?: boolean
filterCollections?: string[]
excludeIdentity?: boolean
excludeAccount?: boolean
excludeCommit?: boolean
excludeSync?: boolean
}
export class Firehose {
private sub: Subscription<com.atproto.sync.subscribeRepos.$Message>
private abortController: AbortController
private destoryDefer: Deferrable
private matchCollection: ((col: string) => boolean) | null = null
constructor(public opts: FirehoseOptions) {
this.destoryDefer = createDeferrable()
this.abortController = new AbortController()
if (this.opts.getCursor && this.opts.runner) {
throw new Error('Must set only `getCursor` or `runner`')
}
if (opts.filterCollections) {
const exact = new Set<string>()
const prefixes: string[] = []
for (const pattern of opts.filterCollections) {
if (pattern.endsWith('.*')) {
prefixes.push(pattern.slice(0, -2))
} else {
exact.add(pattern)
}
}
this.matchCollection = (col: string): boolean => {
if (exact.has(col)) return true
for (const prefix of prefixes) {
if (col.startsWith(prefix)) return true
}
return false
}
}
this.sub = new Subscription({
...opts,
service: opts.service ?? 'wss://bsky.network',
method: com.atproto.sync.subscribeRepos.$lxm,
signal: this.abortController.signal,
getParams: async () => {
let cursor: number | undefined
if (this.opts.runner) {
cursor = await this.opts.runner.getCursor()
} else if (this.opts.getCursor) {
cursor = await this.opts.getCursor()
}
return cursor === undefined ? undefined : { cursor }
},
validate: (value: unknown) => {
const result = com.atproto.sync.subscribeRepos.$message.safeParse(value)
if (result.success) {
return result.value
} else {
this.opts.onError(new FirehoseValidationError(result.reason, value))
}
},
})
}
async start() {
try {
for await (const evt of this.sub) {
if (this.opts.runner) {
const parsed = didAndSeqForEvt(evt)
if (!parsed) {
continue
}
this.opts.runner.trackEvent(parsed.did, parsed.seq, async () => {
const parsed = await this.parseEvt(evt)
for (const write of parsed) {
try {
await this.opts.handleEvent(write)
} catch (err) {
this.opts.onError(new FirehoseHandlerError(err, write))
}
}
})
} else {
await this.processEvt(evt)
}
}
} catch (err) {
if (err && err['name'] === 'AbortError') {
this.destoryDefer.resolve()
return
}
this.opts.onError(new FirehoseSubscriptionError(err))
await wait(this.opts.subscriptionReconnectDelay ?? 3000)
return this.start()
}
}
private async parseEvt(
evt: com.atproto.sync.subscribeRepos.$Message,
): Promise<Event[]> {
try {
if (com.atproto.sync.subscribeRepos.commit.$isTypeOf(evt)) {
if (this.opts.excludeCommit) return []
return this.opts.unauthenticatedCommits
? await parseCommitUnauthenticated(evt, this.matchCollection)
: await parseCommitAuthenticated(
this.opts.idResolver,
evt,
this.matchCollection,
)
} else if (com.atproto.sync.subscribeRepos.account.$isTypeOf(evt)) {
if (this.opts.excludeAccount) return []
const parsed = parseAccount(evt)
return parsed ? [parsed] : []
} else if (com.atproto.sync.subscribeRepos.identity.$isTypeOf(evt)) {
if (this.opts.excludeIdentity) return []
const parsed = await parseIdentity(
this.opts.idResolver,
evt,
this.opts.unauthenticatedHandles,
)
return parsed ? [parsed] : []
} else if (com.atproto.sync.subscribeRepos.sync.$isTypeOf(evt)) {
if (this.opts.excludeSync) return []
const parsed = await parseSync(evt)
return parsed ? [parsed] : []
} else {
return []
}
} catch (err) {
this.opts.onError(new FirehoseParseError(err, evt))
return []
}
}
private async processEvt(evt: com.atproto.sync.subscribeRepos.$Message) {
const parsed = await this.parseEvt(evt)
for (const write of parsed) {
try {
await this.opts.handleEvent(write)
} catch (err) {
this.opts.onError(new FirehoseHandlerError(err, write))
}
}
}
async destroy(): Promise<void> {
this.abortController.abort()
await this.destoryDefer.complete
}
}
export const parseCommitAuthenticated = async (
idResolver: IdResolver,
evt: com.atproto.sync.subscribeRepos.Commit,
matchCollection?: ((col: string) => boolean) | null,
forceKeyRefresh = false,
): Promise<CommitEvt[]> => {
const did = evt.repo
const ops = maybeFilterOps(evt.ops, matchCollection)
if (ops.length === 0) {
return []
}
const claims = ops.map((op) => {
const { collection, rkey } = parseDataKey(op.path)
return {
collection,
rkey,
cid: op.action === 'delete' ? null : op.cid,
}
})
const key = await idResolver.did.resolveAtprotoKey(did, forceKeyRefresh)
const verifiedCids: Record<string, Cid | null> = {}
try {
const results = await verifyProofs(evt.blocks, claims, did, key)
results.verified.forEach((op) => {
const path = formatDataKey(op.collection, op.rkey)
verifiedCids[path] = op.cid
})
} catch (err) {
if (err instanceof RepoVerificationError && !forceKeyRefresh) {
return parseCommitAuthenticated(idResolver, evt, matchCollection, true)
}
throw err
}
const verifiedOps: com.atproto.sync.subscribeRepos.RepoOp[] = ops.filter(
(op) => {
const verifiedCid = verifiedCids[op.path]
if (op.action === 'delete') {
return verifiedCid === null
} else {
return (
op.cid != null && verifiedCid != null && verifiedCid.equals(op.cid)
)
}
},
)
return formatCommitOps(evt, verifiedOps, {
skipCidVerification: true, // already checked via verifyProofs()
})
}
export const parseCommitUnauthenticated = async (
evt: com.atproto.sync.subscribeRepos.Commit,
matchCollection?: ((col: string) => boolean) | null,
): Promise<CommitEvt[]> => {
const ops = maybeFilterOps(evt.ops, matchCollection)
return formatCommitOps(evt, ops)
}
const maybeFilterOps = (
ops: com.atproto.sync.subscribeRepos.RepoOp[],
matchCollection?: ((col: string) => boolean) | null,
): com.atproto.sync.subscribeRepos.RepoOp[] => {
if (!matchCollection) return ops
return ops.filter((op) => {
const { collection } = parseDataKey(op.path)
return matchCollection(collection)
})
}
const formatCommitOps = async (
evt: com.atproto.sync.subscribeRepos.Commit,
ops: com.atproto.sync.subscribeRepos.RepoOp[],
options?: { skipCidVerification: boolean },
) => {
const car = await readCar(evt.blocks, options)
const evts: CommitEvt[] = []
for (const op of ops) {
const uri = AtUri.make(evt.repo, op.path)
const meta: CommitMeta = {
seq: evt.seq,
time: evt.time,
commit: evt.commit,
blocks: car.blocks,
rev: evt.rev,
uri,
did: uri.did,
collection: uri.collection,
rkey: uri.rkey,
}
if (op.action === 'create' || op.action === 'update') {
if (!op.cid) continue
const recordBytes = car.blocks.get(op.cid)
if (!recordBytes) continue
const record = cborToLexRecord(recordBytes)
evts.push({
...meta,
event: op.action as 'create' | 'update',
cid: op.cid,
record,
})
}
if (op.action === 'delete') {
evts.push({
...meta,
event: 'delete',
})
}
}
return evts
}
export const parseSync = async (
evt: com.atproto.sync.subscribeRepos.Sync,
): Promise<SyncEvt | null> => {
const car = await readCarWithRoot(evt.blocks)
return {
event: 'sync',
seq: evt.seq,
time: evt.time,
did: evt.did,
cid: car.root,
rev: evt.rev,
blocks: car.blocks,
}
}
export const parseIdentity = async (
idResolver: IdResolver,
evt: com.atproto.sync.subscribeRepos.Identity,
unauthenticated = false,
): Promise<IdentityEvt | null> => {
const res = await idResolver.did.resolve(evt.did)
const handle =
res && !unauthenticated
? await verifyHandle(idResolver, evt.did, res)
: undefined
return {
event: 'identity',
seq: evt.seq,
time: evt.time,
did: evt.did,
handle,
didDocument: res ?? undefined,
}
}
const verifyHandle = async (
idResolver: IdResolver,
did: string,
didDoc: DidDocument,
): Promise<string | undefined> => {
const { handle } = parseToAtprotoDocument(didDoc)
if (!handle) {
return undefined
}
const res = await idResolver.handle.resolve(handle)
return res === did ? handle : undefined
}
export const parseAccount = (
evt: com.atproto.sync.subscribeRepos.Account,
): AccountEvt | undefined => {
if (evt.status && !isValidStatus(evt.status)) return
return {
event: 'account',
seq: evt.seq,
time: evt.time,
did: evt.did,
active: evt.active,
status: evt.status as AccountStatus | undefined,
}
}
const isValidStatus = (str: string): str is AccountStatus => {
return ['takendown', 'suspended', 'deleted', 'deactivated'].includes(str)
}
export class FirehoseValidationError extends Error {
constructor(
err: unknown,
public value: unknown,
) {
super('error in firehose event lexicon validation', { cause: err })
}
}
export class FirehoseParseError extends Error {
constructor(
err: unknown,
public event: com.atproto.sync.subscribeRepos.$Message,
) {
super('error in parsing and authenticating firehose event', { cause: err })
}
}
export class FirehoseSubscriptionError extends Error {
constructor(err: unknown) {
super('error on firehose subscription', { cause: err })
}
}
export class FirehoseHandlerError extends Error {
constructor(
err: unknown,
public event: Event,
) {
super('error in firehose event handler', { cause: err })
}
}