Something went wrong. Try again.
a love letter to tangled (android, iOS, and a search API)
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083import { Client, simpleFetchHandler } from '@atcute/client'import type { BaseSchema, InferInput } from '@atcute/lexicons/validations'import * as v from '@atcute/lexicons/validations'import { is } from '@atcute/lexicons'import { ComBadExampleIdentityResolveMiniDoc } from '@atcute/microcosm'import { ShTangledFeedComment, ShTangledFeedCountStars, ShTangledFeedListCommentsBy, ShTangledFeedListReactionsBy, ShTangledFeedListStarsBy, ShTangledFeedReaction, ShTangledFeedStar, ShTangledGitListRefUpdates, ShTangledGitListRefUpdatesBy, ShTangledGitRefUpdate, ShTangledGraphFollow, ShTangledGraphListFollowsBy, ShTangledGraphListVouchesBy, ShTangledGraphVouch, ShTangledKnotListMembersBy, ShTangledLabelDefinition, ShTangledLabelListDefinitions, ShTangledLabelListOpsBy, ShTangledLabelOp, ShTangledPipeline, ShTangledPipelineListPipelinesBy, ShTangledPipelineListStatusesBy, ShTangledPipelineStatus, ShTangledActorGetProfile, ShTangledActorProfile, ShTangledRepo, ShTangledRepoArchive, ShTangledRepoArtifact, ShTangledRepoBlob, ShTangledRepoBranches, ShTangledRepoCompare, ShTangledRepoCountIssues, ShTangledRepoCountPulls, ShTangledRepoDiff, ShTangledRepoGetDefaultBranch, ShTangledRepoGetRepoByRepoDid, ShTangledRepoGetRepos, ShTangledRepoGetRepo, ShTangledRepoIssue, ShTangledRepoIssueListStatesBy, ShTangledRepoIssueState, ShTangledRepoLanguages, ShTangledRepoLog, ShTangledRepoListArtifactsBy, ShTangledRepoListCollaborators, ShTangledRepoListCollaboratorsBy, ShTangledRepoListIssuesBy, ShTangledRepoListPullsBy, ShTangledRepoListRepos, ShTangledRepoPull, ShTangledRepoPullListStatusesBy, ShTangledRepoPullStatus, ShTangledRepoTree, ShTangledRepoTags, ShTangledSearchQuery, ShTangledSpindleListMembersBy,} from '@atcute/tangled'import { bobbinCoverageSchema, bobbinKnotListKeysSchema, bobbinKnotOwnerSchema, bobbinKnotVersionSchema, bobbinRepoBlobSchema, DEFAULT_BOBBIN_SERVICE,} from './contracts'import type { BobbinCoverage } from './contracts'import { BobbinError, errorFromException, errorFromResponse } from './errors'import { createRequestKey, RequestCache, STALE_TIMES } from './cache'
/** A Bobbin record view after its embedded value has passed schema validation. */export interface ValidatedRecordView<T> { uri: string cid?: string value: T}
/** One cursor-based page returned by an indexed Bobbin query. */export interface CursorPage<T> { items: T[] cursor?: string}
export const actorActivityKinds = [ 'comments', 'reactions', 'stars', 'follows', 'vouches', 'issues', 'pulls', 'issue-states', 'pull-statuses', 'ref-updates', 'collaborators', 'label-operations', 'pipelines', 'pipeline-statuses', 'artifacts', 'knot-memberships', 'spindle-memberships',] as const
export type ActorActivityKind = (typeof actorActivityKinds)[number]
export interface ActorActivityItem { cid?: string uri?: string value: Record<string, unknown>}
export interface ActorActivityOptions extends ListReposOptions { state?: 'open' | 'closed' status?: 'open' | 'closed' | 'merged'}
export type RepositoryCounts = { issues: number; pulls: number; stars: number }
export const MAX_TEXT_BLOB_BYTES = 512 * 1024export const MAX_RENDERED_PATCH_BYTES = 512 * 1024
export type RepositoryPatch = | { kind: 'patch'; bytes: number; text: string; contentType?: string; filename?: string } | { kind: 'too-large'; bytes?: number; contentType?: string; filename?: string }
export type RepositoryArchiveFormat = Extract<ShTangledRepoArchive.$params['format'], 'tar.gz' | 'zip'>
export type RepositoryArchiveOptions = RequestOptions & { format?: RepositoryArchiveFormat ifModifiedSince?: string ifNoneMatch?: string prefix?: string range?: string ref?: string}
export type RepositoryDownload = { body: ReadableStream<Uint8Array> | null cacheControl?: string contentLength?: number contentRange?: string contentType?: string etag?: string filename?: string lastModified?: string status: number}
export type RepositorySignature = { email?: string; name: string; when?: string }
export type RepositoryBranch = { author?: RepositorySignature hash: string isDefault: boolean message?: string name: string when?: string}
export type RepositoryTag = { hash: string; message?: string; name: string; tagger?: RepositorySignature }
export type RepositoryCommit = { author?: RepositorySignature committer?: RepositorySignature hash: string message: string parents: readonly string[] tree?: string}
export type RepositoryLogPage = CursorPage<RepositoryCommit> & { ref: string; total?: number }
export type RepositoryRefOptions = RequestOptions & { cursor?: string; limit?: number }
export type RepositoryLogOptions = RepositoryRefOptions & { path?: string; ref: string }
type ActorDid = ShTangledRepoListIssuesBy.$params['subject']
/** Constructor options for a configurable, testable Bobbin boundary. */export interface BobbinClientOptions { cache?: RequestCache service?: string | URL fetch?: typeof globalThis.fetch}
/** Options shared by abortable Bobbin requests. */export interface RequestOptions { cache?: 'default' | 'reload' signal?: AbortSignal}
/** Parameters accepted by the repository list endpoint. */export interface ListReposOptions extends RequestOptions { cursor?: string limit?: number order?: 'asc' | 'desc'}
/** A search hit whose embedded value has been validated and mapped. */export interface ValidatedSearchHit<T> { uri: string cid?: string nsid: string score: number value: T}
/** Parameters for Bobbin's full-text search query. */export type SearchParams = ShTangledSearchQuery.$params
type XrpcResponse<T> = | { ok: true; data: T; status: number; headers: Headers } | { ok: false; data: { error: string; message?: string }; status: number; headers: Headers }
type SuccessData<TResponse> = TResponse extends { ok: true; data: infer TData } ? TData : never
interface BobbinSearchResponse { cursor?: string hits: Array<{ uri: string; cid?: string; nsid: string; score: number; value: Record<string, unknown> }>}
/** * The read-only XRPC boundary used by Twisted views and feature modules. * * Published endpoints use atcute's generated query schemas. Methods that return * embedded records perform a second validation step with the record schema. */export class BobbinClient { readonly service: string readonly #cache: RequestCache readonly #fetch: typeof globalThis.fetch readonly #rpc: Client
constructor(options: BobbinClientOptions = {}) { this.service = normalizeBobbinService(options.service ?? DEFAULT_BOBBIN_SERVICE) this.#cache = options.cache ?? new RequestCache() this.#fetch = options.fetch ?? globalThis.fetch this.#rpc = new Client({ handler: simpleFetchHandler({ service: this.service, fetch: this.#fetch }) }) }
/** Returns Bobbin's current Hydrant ingestion coverage. */ getCoverage(options: RequestOptions = {}): Promise<BobbinCoverage> { return this.#cached('sh.tangled.bobbin.getCoverage', {}, options, STALE_TIMES.coverage, (signal) => this.#rpc.call(bobbinCoverageSchema, { signal }), ) }
/** Resolves a handle or DID through Microcosm's typed identity query. */ resolveIdentity( identifier: ComBadExampleIdentityResolveMiniDoc.$params['identifier'], options: RequestOptions = {}, ): Promise<ComBadExampleIdentityResolveMiniDoc.$output> { return this.#cached( 'com.bad-example.identity.resolveMiniDoc', { identifier }, options, STALE_TIMES.identity, (signal) => this.#rpc.call(ComBadExampleIdentityResolveMiniDoc, { params: { identifier }, signal }), ) }
/** Fetches and validates one Tangled actor profile record. */ async getProfile( actor: ShTangledActorGetProfile.$params['actor'], options: RequestOptions = {}, ): Promise<ValidatedRecordView<ShTangledActorProfile.Main>> { const view = await this.#cached('sh.tangled.actor.getProfile', { actor }, options, STALE_TIMES.record, (signal) => this.#rpc.call(ShTangledActorGetProfile, { params: { actor }, signal }), )
return validateRecordView( { ...view, value: normalizeProfilePlaceholders(view.value) }, ShTangledActorProfile.mainSchema, 'actor profile', ) }
/** Fetches a public profile image through CORS so it can be displayed under COEP. */ async getProfileAvatar(pds: string, did: string, cid: string, options: RequestOptions = {}): Promise<Blob> { let url: URL try { url = new URL('/xrpc/com.atproto.sync.getBlob', pds) } catch (error) { throw new BobbinError('invalid-request', 'The profile image address is invalid', { cause: error }) } if (url.protocol !== 'https:') throw new BobbinError('invalid-request', 'Profile images must use HTTPS') url.searchParams.set('did', did) url.searchParams.set('cid', cid)
try { const response = await this.#fetch(url, { credentials: 'omit', mode: 'cors', referrerPolicy: 'no-referrer', signal: options.signal, }) if (!response.ok) { throw new BobbinError( response.status === 404 ? 'not-found' : 'service-unavailable', 'The profile image is unavailable', { status: response.status }, ) } const contentType = response.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase() if (!contentType?.startsWith('image/')) { throw new BobbinError('malformed-response', 'The profile image has an unsupported format') } const blob = await response.blob() if (blob.size > 5 * 1024 * 1024) throw new BobbinError('malformed-response', 'The profile image is too large') return blob } catch (error) { throw errorFromException(error) } }
/** Fetches and validates one Tangled repository record. */ async getRepo( repo: ShTangledRepoGetRepo.$params['repo'], options: RequestOptions = {}, ): Promise<ValidatedRecordView<ShTangledRepo.Main>> { const view = await this.#cached('sh.tangled.repo.getRepo', { repo }, options, STALE_TIMES.record, (signal) => this.#rpc.call(ShTangledRepoGetRepo, { params: { repo }, signal }), )
return validateRecordView(view, ShTangledRepo.mainSchema, 'repository') }
/** Fetches and validates one repository by its repository DID. */ async getRepoByRepoDid( repoDid: ShTangledRepoGetRepoByRepoDid.$params['repoDid'], options: RequestOptions = {}, ): Promise<ValidatedRecordView<ShTangledRepo.Main>> { const view = await this.#cached( 'sh.tangled.repo.getRepoByRepoDid', { repoDid }, options, STALE_TIMES.record, (signal) => this.#rpc.call(ShTangledRepoGetRepoByRepoDid, { params: { repoDid }, signal }), )
return validateRecordView(view, ShTangledRepo.mainSchema, 'repository') }
/** Fetches and validates repository records in one ordered batch. */ async getRepos( repos: ShTangledRepoGetRepos.$params['repos'], options: RequestOptions = {}, ): Promise<ValidatedRecordView<ShTangledRepo.Main>[]> { const data = await this.#cached('sh.tangled.repo.getRepos', { repos }, options, STALE_TIMES.record, (signal) => this.#rpc.call(ShTangledRepoGetRepos, { params: { repos }, signal }), )
return data.items.map((item) => validateRecordView(item, ShTangledRepo.mainSchema, 'repository')) }
/** Lists an actor's repositories and validates every embedded record. */ async listRepos( subject: ShTangledRepoListRepos.$params['subject'], options: ListReposOptions = {}, ): Promise<CursorPage<ValidatedRecordView<ShTangledRepo.Main>>> { const parameters = { subject, cursor: options.cursor, limit: options.limit, order: options.order } const data = await this.#cached('sh.tangled.repo.listRepos', parameters, options, STALE_TIMES.list, (signal) => this.#rpc.call(ShTangledRepoListRepos, { params: parameters, signal }), )
return { items: data.items.map((item) => validateRecordView(item, ShTangledRepo.mainSchema, 'repository list item')), cursor: data.cursor, } }
/** Lists one actor-authored record family through its generated reverse-lookup schema. */ async listActorActivity( kind: ActorActivityKind, subject: ActorDid, options: ActorActivityOptions = {}, ): Promise<CursorPage<ActorActivityItem>> { const params = { subject, cursor: options.cursor, limit: options.limit ?? 10, order: options.order ?? 'desc' } switch (kind) { case 'comments': return this.#actorRecords(kind, params, options, ShTangledFeedComment.mainSchema, (signal) => this.#rpc.call(ShTangledFeedListCommentsBy, { params, signal }), ) case 'reactions': return this.#actorRecords(kind, params, options, ShTangledFeedReaction.mainSchema, (signal) => this.#rpc.call(ShTangledFeedListReactionsBy, { params, signal }), ) case 'stars': return this.#actorRecords(kind, params, options, ShTangledFeedStar.mainSchema, (signal) => this.#rpc.call(ShTangledFeedListStarsBy, { params, signal }), ) case 'follows': return this.#actorRecords(kind, params, options, ShTangledGraphFollow.mainSchema, (signal) => this.#rpc.call(ShTangledGraphListFollowsBy, { params, signal }), ) case 'vouches': return this.#actorRecords(kind, params, options, ShTangledGraphVouch.mainSchema, (signal) => this.#rpc.call(ShTangledGraphListVouchesBy, { params, signal }), ) case 'issues': { const filtered = { ...params, state: options.state } return this.#actorRecords(kind, filtered, options, ShTangledRepoIssue.mainSchema, (signal) => this.#rpc.call(ShTangledRepoListIssuesBy, { params: filtered, signal }), ) } case 'pulls': { const filtered = { ...params, status: options.status } return this.#actorRecords(kind, filtered, options, ShTangledRepoPull.mainSchema, (signal) => this.#rpc.call(ShTangledRepoListPullsBy, { params: filtered, signal }), ) } case 'issue-states': return this.#actorRecords(kind, params, options, ShTangledRepoIssueState.mainSchema, (signal) => this.#rpc.call(ShTangledRepoIssueListStatesBy, { params, signal }), ) case 'pull-statuses': return this.#actorRecords(kind, params, options, ShTangledRepoPullStatus.mainSchema, (signal) => this.#rpc.call(ShTangledRepoPullListStatusesBy, { params, signal }), ) case 'ref-updates': return this.#actorRecords(kind, params, options, ShTangledGitRefUpdate.mainSchema, (signal) => this.#rpc.call(ShTangledGitListRefUpdatesBy, { params, signal }), ) case 'collaborators': return this.#actorRecords(kind, params, options, undefined, (signal) => this.#rpc.call(ShTangledRepoListCollaboratorsBy, { params, signal }), ) case 'label-operations': return this.#actorRecords(kind, params, options, ShTangledLabelOp.mainSchema, (signal) => this.#rpc.call(ShTangledLabelListOpsBy, { params, signal }), ) case 'pipelines': return this.#actorRecords(kind, params, options, ShTangledPipeline.mainSchema, (signal) => this.#rpc.call(ShTangledPipelineListPipelinesBy, { params, signal }), ) case 'pipeline-statuses': return this.#actorRecords(kind, params, options, ShTangledPipelineStatus.mainSchema, (signal) => this.#rpc.call(ShTangledPipelineListStatusesBy, { params, signal }), ) case 'artifacts': return this.#actorRecords(kind, params, options, ShTangledRepoArtifact.mainSchema, (signal) => this.#rpc.call(ShTangledRepoListArtifactsBy, { params, signal }), ) case 'knot-memberships': return this.#actorRecords(kind, params, options, undefined, (signal) => this.#rpc.call(ShTangledKnotListMembersBy, { params, signal }), ) case 'spindle-memberships': return this.#actorRecords(kind, params, options, undefined, (signal) => this.#rpc.call(ShTangledSpindleListMembersBy, { params, signal }), ) } }
getRepositoryLanguages(repo: ShTangledRepoLanguages.$params['repo'], options: RequestOptions = {}) { return this.#cached('sh.tangled.repo.languages', { repo, ref: 'HEAD' }, options, STALE_TIMES.record, (signal) => this.#rpc.call(ShTangledRepoLanguages, { params: { repo, ref: 'HEAD' }, signal }), ) }
getRepositoryTree( repo: ShTangledRepoTree.$params['repo'], params: { path?: string; ref?: string } = {}, options: RequestOptions = {}, ) { const request = { repo, ref: params.ref ?? 'HEAD', path: params.path ?? '' } return this.#cached('sh.tangled.repo.tree', request, options, STALE_TIMES.record, (signal) => this.#rpc.call(ShTangledRepoTree, { params: request, signal }), ) }
getRepositoryBlob( repo: string, ref: ShTangledRepoBlob.$params['ref'], path: ShTangledRepoBlob.$params['path'], options: RequestOptions = {}, ) { const params = { repo, ref, path, raw: false } return this.#cached('sh.tangled.repo.blob', params, options, STALE_TIMES.record, (signal) => this.#rpc.call(bobbinRepoBlobSchema, { params, signal }), ) }
getRepositoryDefaultBranch(repo: string, options: RequestOptions = {}) { return this.#cached('sh.tangled.repo.getDefaultBranch', { repo }, options, STALE_TIMES.record, (signal) => this.#rpc.call(ShTangledRepoGetDefaultBranch, { params: { repo }, signal }), ) }
async listRepositoryBranches( repo: string, options: RepositoryRefOptions = {}, ): Promise<CursorPage<RepositoryBranch>> { const limit = clampPageSize(options.limit) const cursor = parseOffsetCursor(options.cursor) const params = { repo, cursor: cursor ? String(cursor) : undefined, limit: limit + 1 } const blob = await this.#cached('sh.tangled.repo.branches', params, options, STALE_TIMES.list, (signal) => this.#rpc.call(ShTangledRepoBranches, { as: 'blob', params, signal }), ) const branches = parseBranches(await parseJsonBlob(blob, 'branches')) return { items: branches.slice(0, limit), cursor: branches.length > limit ? String(cursor + limit) : undefined } }
async listRepositoryTags(repo: string, options: RepositoryRefOptions = {}): Promise<CursorPage<RepositoryTag>> { const limit = clampPageSize(options.limit) const cursor = parseOffsetCursor(options.cursor) const params = { repo, cursor: cursor ? String(cursor) : undefined, limit: limit + 1 } const blob = await this.#cached('sh.tangled.repo.tags', params, options, STALE_TIMES.list, (signal) => this.#rpc.call(ShTangledRepoTags, { as: 'blob', params, signal }), ) const tags = parseTags(await parseJsonBlob(blob, 'tags')) return { items: tags.slice(0, limit), cursor: tags.length > limit ? String(cursor + limit) : undefined } }
async getRepositoryLog(repo: string, options: RepositoryLogOptions): Promise<RepositoryLogPage> { const limit = Math.min(100, Math.max(1, options.limit ?? 20)) const page = Math.max(1, Number.parseInt(options.cursor ?? '1', 10) || 1) const params = { repo, ref: options.ref, path: options.path ?? '', cursor: String(page), limit } const blob = await this.#cached('sh.tangled.repo.log', params, options, STALE_TIMES.list, (signal) => this.#rpc.call(ShTangledRepoLog, { as: 'blob', params, signal }), ) return parseRepositoryLog(await parseJsonBlob(blob, 'commit history'), options.ref, page, limit) }
getRepositoryDiff(repo: string, ref: string, options: RequestOptions = {}): Promise<RepositoryPatch> { const params = { repo, ref } if (!is(ShTangledRepoDiff.mainSchema.params, params)) { return Promise.reject(new BobbinError('invalid-request', 'The repository revision is invalid')) } return this.#getRepositoryPatch(this.repositoryDiffUrl(repo, ref), options) }
getRepositoryCompare(repo: string, base: string, head: string, options: RequestOptions = {}): Promise<RepositoryPatch> { const params = { repo, rev1: base, rev2: head } if (!is(ShTangledRepoCompare.mainSchema.params, params)) { return Promise.reject(new BobbinError('invalid-request', 'The comparison revisions are invalid')) } return this.#getRepositoryPatch(this.repositoryCompareUrl(repo, base, head), options) }
repositoryDiffUrl(repo: string, ref: string): string { return this.#repositoryQueryUrl('sh.tangled.repo.diff', { repo, ref }) }
repositoryCompareUrl(repo: string, base: string, head: string): string { return this.#repositoryQueryUrl('sh.tangled.repo.compare', { repo, rev1: base, rev2: head }) }
repositoryBlobUrl(repo: string, ref: string, path: string): string { const url = new URL('/xrpc/sh.tangled.repo.blob', this.service) url.searchParams.set('repo', repo) url.searchParams.set('ref', ref) url.searchParams.set('path', path) url.searchParams.set('raw', 'true') return url.href }
async listRepositoryCollaborators(subject: ActorDid, options: RequestOptions = {}) { const params = { subject, limit: 8, order: 'desc' as const } return this.#cached('sh.tangled.repo.listCollaborators', params, options, STALE_TIMES.list, (signal) => this.#rpc.call(ShTangledRepoListCollaborators, { params, signal }), ) }
async listRepositoryLabels(subject: string, options: RequestOptions = {}) { const params = { subject, limit: 12, order: 'asc' } const data = await this.#cached('sh.tangled.label.listDefinitions', params, options, STALE_TIMES.list, (signal) => this.#rpc.call(ShTangledLabelListDefinitions, { params, signal }), ) return data.items.map((item) => validateRecordView(item, ShTangledLabelDefinition.mainSchema, 'label definition')) }
async listRepositoryRefUpdates(subject: ActorDid, options: RequestOptions = {}) { const params = { subject, limit: 6, order: 'desc' as const } const data = await this.#cached('sh.tangled.git.listRefUpdates', params, options, STALE_TIMES.list, (signal) => this.#rpc.call(ShTangledGitListRefUpdates, { params, signal }), ) return data.items.map((item) => validateRecordView(item, ShTangledGitRefUpdate.mainSchema, 'ref update')) }
async getRepositoryCounts(subject: ActorDid, options: RequestOptions = {}): Promise<RepositoryCounts> { const request = (nsid: string, call: (signal: AbortSignal) => Promise<XrpcResponse<{ count: number }>>) => this.#cached(nsid, { subject }, options, STALE_TIMES.list, call) const [stars, issues, pulls] = await Promise.all([ request('sh.tangled.feed.countStars', (signal) => this.#rpc.call(ShTangledFeedCountStars, { params: { subject }, signal }), ), request('sh.tangled.repo.countIssues', (signal) => this.#rpc.call(ShTangledRepoCountIssues, { params: { subject }, signal }), ), request('sh.tangled.repo.countPulls', (signal) => this.#rpc.call(ShTangledRepoCountPulls, { params: { subject }, signal }), ), ]) return { stars: stars.count, issues: issues.count, pulls: pulls.count } }
repositoryArchiveUrl( repo: ShTangledRepoArchive.$params['repo'], format: RepositoryArchiveFormat = 'tar.gz', ref = 'HEAD', prefix?: string, ): string { return this.#repositoryQueryUrl('sh.tangled.repo.archive', { repo, ref, format, prefix }) }
async getRepositoryArchive(repo: string, options: RepositoryArchiveOptions = {}): Promise<RepositoryDownload> { const params = { repo, ref: options.ref ?? 'HEAD', format: options.format ?? 'tar.gz', prefix: options.prefix, } if (!is(ShTangledRepoArchive.mainSchema.params, params)) { throw new BobbinError('invalid-request', 'The archive request is invalid') }
const headers = new Headers() if (options.range) headers.set('range', options.range) if (options.ifNoneMatch) headers.set('if-none-match', options.ifNoneMatch) if (options.ifModifiedSince) headers.set('if-modified-since', options.ifModifiedSince)
try { const response = await this.#fetch(this.repositoryArchiveUrl(repo, params.format, params.ref, params.prefix), { headers, signal: options.signal, cache: options.cache === 'reload' ? 'reload' : 'default', credentials: 'omit', mode: 'cors', referrerPolicy: 'no-referrer', }) if (!response.ok && response.status !== 304) throw await responseError(response) return downloadMetadata(response) } catch (error) { throw errorFromException(error) } }
/** * Searches Bobbin and requires a schema for each collection the caller accepts. * Unknown collections fail closed instead of exposing an unvalidated value. */ async search<TSchemas extends Readonly<Record<string, BaseSchema>>>( params: SearchParams, schemas: TSchemas, options: RequestOptions = {}, ): Promise<CursorPage<ValidatedSearchHit<InferInput<TSchemas[keyof TSchemas]>>>> { if (!is(ShTangledSearchQuery.mainSchema.params, params)) { throw new BobbinError('invalid-request', 'The search parameters are invalid') } const data = await this.#cached('sh.tangled.search.query', params, options, STALE_TIMES.search, (signal) => this.#rpc.get('sh.tangled.search.query', { params, signal }), ) const validated = validateSearchResponse(data)
return { items: validated.hits.map((hit) => { const schema = schemas[hit.nsid] if (schema === undefined) { throw new BobbinError('malformed-response', `Bobbin returned an unsupported search record: ${hit.nsid}`) }
return { uri: hit.uri, cid: hit.cid, nsid: hit.nsid, score: hit.score, value: validateEmbeddedRecord(schema, hit.value, `search hit ${hit.uri}`), } }), cursor: validated.cursor, } }
/** Queries a knot's public owner through Bobbin's documented parameter overlay. */ getKnotOwner(knot: string, options: RequestOptions = {}) { return this.#cached('sh.tangled.owner', { knot }, options, STALE_TIMES.diagnostics, (signal) => this.#rpc.call(bobbinKnotOwnerSchema, { params: { knot }, signal }), ) }
/** Queries a knot's public version through Bobbin's documented parameter overlay. */ getKnotVersion(knot: string, options: RequestOptions = {}) { return this.#cached('sh.tangled.knot.version', { knot }, options, STALE_TIMES.diagnostics, (signal) => this.#rpc.call(bobbinKnotVersionSchema, { params: { knot }, signal }), ) }
/** Lists a knot's public keys through Bobbin's documented parameter overlay. */ listKnotKeys(knot: string, params: { cursor?: string; limit?: number } = {}, options: RequestOptions = {}) { const parameters = { knot, ...params } return this.#cached('sh.tangled.knot.listKeys', parameters, options, STALE_TIMES.list, (signal) => this.#rpc.call(bobbinKnotListKeysSchema, { params: parameters, signal }), ) }
async #cached<TResponse extends XrpcResponse<unknown>>( nsid: string, parameters: unknown, options: RequestOptions, staleTimeMs: number, request: (signal: AbortSignal) => Promise<TResponse>, ): Promise<SuccessData<TResponse>> { try { return await this.#cache.get( createRequestKey(nsid, parameters), (signal) => this.#request(() => request(signal)), { force: options.cache === 'reload', signal: options.signal, staleTimeMs }, ) } catch (error) { throw errorFromException(error) } }
async #actorRecords<const TSchema extends BaseSchema>( kind: ActorActivityKind, parameters: Record<string, unknown>, options: RequestOptions, schema: TSchema | undefined, request: ( signal: AbortSignal, ) => Promise<XrpcResponse<{ items: readonly { uri?: string; cid?: string; value?: unknown }[]; cursor?: string }>>, ): Promise<CursorPage<ActorActivityItem>> { const data = await this.#cached(`actor:${kind}`, parameters, options, STALE_TIMES.list, request) return { items: data.items.map((item) => ({ uri: item.uri, cid: item.cid, value: schema && item.value !== undefined ? (validateEmbeddedRecord(schema, item.value, `${kind} activity`) as Record<string, unknown>) : (item as Record<string, unknown>), })), cursor: data.cursor, } }
async #request<TResponse extends XrpcResponse<unknown>>( request: () => Promise<TResponse>, ): Promise<SuccessData<TResponse>> { try { const response = await request() if (!response.ok) { throw errorFromResponse(response) } return response.data as SuccessData<TResponse> } catch (error) { throw errorFromException(error) } }
async #getRepositoryPatch(url: string, options: RequestOptions): Promise<RepositoryPatch> { try { const response = await this.#fetch(url, { headers: { accept: 'text/x-diff, text/plain;q=0.9, application/octet-stream;q=0.5' }, signal: options.signal, cache: options.cache === 'reload' ? 'reload' : 'default', credentials: 'omit', mode: 'cors', referrerPolicy: 'no-referrer', }) if (!response.ok) throw await responseError(response)
const metadata = downloadMetadata(response) if (metadata.contentLength !== undefined && metadata.contentLength > MAX_RENDERED_PATCH_BYTES) { await response.body?.cancel() return { kind: 'too-large', bytes: metadata.contentLength, contentType: metadata.contentType, filename: metadata.filename, } }
const reader = response.body?.getReader() if (!reader) return { kind: 'patch', bytes: 0, text: '', contentType: metadata.contentType } const decoder = new TextDecoder() let bytes = 0 let text = '' for (;;) { const { done, value } = await reader.read() if (done) break bytes += value.byteLength if (bytes > MAX_RENDERED_PATCH_BYTES) { await reader.cancel() return { kind: 'too-large', bytes, contentType: metadata.contentType, filename: metadata.filename } } text += decoder.decode(value, { stream: true }) } text += decoder.decode() return { kind: 'patch', bytes, text, contentType: metadata.contentType, filename: metadata.filename } } catch (error) { throw errorFromException(error) } }
#repositoryQueryUrl(nsid: string, params: Record<string, string | undefined>): string { const url = new URL(`/xrpc/${nsid}`, this.service) for (const [name, value] of Object.entries(params)) if (value !== undefined) url.searchParams.set(name, value) return url.href }}
/** Creates a Bobbin boundary with the default service and browser fetch. */export function createBobbinClient(options: BobbinClientOptions = {}): BobbinClient { return new BobbinClient(options)}
/** Validates an embedded record value with an atcute-generated schema. */export function validateEmbeddedRecord<const TSchema extends BaseSchema>( schema: TSchema, value: unknown, context: string,): InferInput<TSchema> { if (!is(schema, value)) { throw new BobbinError('malformed-response', `Bobbin returned an invalid ${context}`) } return value}
/** Normalizes and validates a configurable Bobbin base URL. */export function normalizeBobbinService(service: string | URL): string { const url = new URL(service) if (url.protocol !== 'https:') { throw new TypeError('Data source addresses must use HTTPS') } if (url.username || url.password || url.search || url.hash) { throw new TypeError('Data source addresses cannot contain credentials, a query, or a page fragment') }
return url.toString().replace(/\/$/, '')}
function validateRecordView<const TSchema extends BaseSchema>( view: { uri: string; cid?: string; value: unknown }, schema: TSchema, context: string,): ValidatedRecordView<InferInput<TSchema>> { return { uri: view.uri, cid: view.cid, value: validateEmbeddedRecord(schema, view.value, context) }}
/** Removes blank placeholders emitted by older profile writers before strict schema validation. */function normalizeProfilePlaceholders(value: unknown): unknown { if (typeof value !== 'object' || value === null || Array.isArray(value)) return value const profile = { ...(value as Record<string, unknown>) } for (const field of ['links', 'stats'] as const) { const items = profile[field] if (Array.isArray(items)) { profile[field] = items.filter((item) => typeof item !== 'string' || item.trim().length > 0) } } return profile}
async function parseJsonBlob(blob: Blob, context: string): Promise<unknown> { try { const text = await readBlobText(blob) return JSON.parse(text) as unknown } catch (error) { throw new BobbinError('malformed-response', `Bobbin returned invalid ${context}`, { cause: error }) }}
function readBlobText(blob: Blob): Promise<string> { if (typeof blob.text === 'function') return blob.text() if (typeof FileReader === 'undefined') return new Response(blob).text() return new Promise((resolve, reject) => { const reader = new FileReader() reader.addEventListener('load', () => resolve(String(reader.result ?? '')), { once: true }) reader.addEventListener('error', () => reject(reader.error), { once: true }) reader.readAsText(blob) })}
function parseBranches(value: unknown): RepositoryBranch[] { const root = responseRecord(value, 'branches') const items = root.branches ?? [] if (!Array.isArray(items)) throw malformed('branches') return items.map((item) => { const branch = responseRecord(item, 'branch') const reference = optionalRecord(branch.reference) const commit = optionalRecord(branch.commit) const author = parseSignature(branch.author ?? commit?.Author ?? commit?.author) return { name: requiredString(branch.name ?? reference?.name, 'branch name'), hash: requiredString(branch.hash ?? reference?.hash, 'branch hash'), isDefault: branch.isDefault === true || branch.is_default === true, message: optionalString(branch.message ?? commit?.Message ?? commit?.message), author, when: optionalString(branch.when) ?? author?.when, } })}
function parseTags(value: unknown): RepositoryTag[] { const root = responseRecord(value, 'tags') const items = root.tags ?? [] if (!Array.isArray(items)) throw malformed('tags') return items.map((item) => { const tag = responseRecord(item, 'tag') const details = optionalRecord(tag.tag) return { name: requiredString(tag.name ?? details?.Name ?? details?.name, 'tag name'), hash: requiredString(tag.hash, 'tag hash'), message: optionalString(tag.message ?? details?.Message ?? details?.message)?.trim(), tagger: parseSignature(details?.Tagger ?? details?.tagger ?? tag.tagger), } })}
function parseRepositoryLog(value: unknown, expectedRef: string, page: number, limit: number): RepositoryLogPage { const root = responseRecord(value, 'commit history') const items = root.commits ?? [] if (!Array.isArray(items)) throw malformed('commit history') const commits = items.map((item) => { const commit = responseRecord(item, 'commit') const hash = optionalString(commit.this) ?? hashString(commit.hash) if (!hash) throw malformed('commit hash') const parents = Array.isArray(commit.parent_hashes) ? commit.parent_hashes.flatMap((parent) => (hashString(parent) ? [hashString(parent)!] : [])) : optionalString(commit.parent) ? [commit.parent as string] : [] return { hash, message: optionalString(commit.message)?.trim() ?? '', author: parseSignature(commit.author ?? commit.Author), committer: parseSignature(commit.committer ?? commit.Committer), parents, tree: optionalString(commit.tree), } }) const actualPage = positiveInteger(root.page) ?? page const pageSize = positiveInteger(root.per_page) ?? limit const total = positiveInteger(root.total) const cursor = total !== undefined && actualPage * pageSize < total ? String(actualPage + 1) : undefined return { items: commits, cursor, ref: optionalString(root.ref) ?? expectedRef, total }}
function parseSignature(value: unknown): RepositorySignature | undefined { const signature = optionalRecord(value) if (!signature) return undefined const name = optionalString(signature.name ?? signature.Name) if (!name) return undefined return { name, email: optionalString(signature.email ?? signature.Email), when: optionalString(signature.when ?? signature.When), }}
function hashString(value: unknown): string | undefined { if (typeof value === 'string' && /^[a-f\d]{40}$/i.test(value)) return value if ( Array.isArray(value) && value.length === 20 && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) ) { return value.map((byte) => (byte as number).toString(16).padStart(2, '0')).join('') } return undefined}
function clampPageSize(value: number | undefined): number { return Math.min(99, Math.max(1, value ?? 20))}
function parseOffsetCursor(value: string | undefined): number { const cursor = Number.parseInt(value ?? '0', 10) return Number.isInteger(cursor) && cursor >= 0 ? cursor : 0}
function responseRecord(value: unknown, context: string): Record<string, unknown> { const record = optionalRecord(value) if (!record) throw malformed(context) return record}
function optionalRecord(value: unknown): Record<string, unknown> | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : undefined}
function requiredString(value: unknown, context: string): string { const text = optionalString(value) if (!text) throw malformed(context) return text}
function optionalString(value: unknown): string | undefined { return typeof value === 'string' ? value : undefined}
function positiveInteger(value: unknown): number | undefined { return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : undefined}
function malformed(context: string): BobbinError { return new BobbinError('malformed-response', `Bobbin returned invalid ${context}`)}
async function responseError(response: Response): Promise<BobbinError> { let data: { error: string; message?: string } = { error: response.statusText || 'Request failed' } try { const value = (await response.json()) as { error?: unknown; message?: unknown } if (typeof value.error === 'string') data.error = value.error if (typeof value.message === 'string') data.message = value.message } catch { // Non-JSON upstream errors still retain their HTTP status and status text. } return errorFromResponse({ status: response.status, headers: response.headers, data })}
function downloadMetadata(response: Response): RepositoryDownload { const lengthHeader = response.headers.get('content-length') const length = lengthHeader === null ? Number.NaN : Number(lengthHeader) return { body: response.body, status: response.status, contentLength: Number.isSafeInteger(length) && length >= 0 ? length : undefined, contentRange: response.headers.get('content-range') ?? undefined, contentType: response.headers.get('content-type') ?? undefined, cacheControl: response.headers.get('cache-control') ?? undefined, etag: response.headers.get('etag') ?? undefined, lastModified: response.headers.get('last-modified') ?? undefined, filename: contentDispositionFilename(response.headers.get('content-disposition')), }}
function contentDispositionFilename(value: string | null): string | undefined { if (!value) return undefined const encoded = /filename\*\s*=\s*UTF-8''([^;]+)/i.exec(value)?.[1] if (encoded) { try { return decodeURIComponent(encoded.trim()) } catch { return encoded.trim() } } return /filename\s*=\s*"([^"]+)"/i.exec(value)?.[1] ?? /filename\s*=\s*([^;]+)/i.exec(value)?.[1]?.trim()}
/** * Validates the search envelope around embedded record values. * * The published schema uses an AT Protocol `unknown` value for Bobbin's * floating-point score. atcute correctly limits that type to objects, while * Bobbin returns a JSON number, so the compatibility check stays local here. */function validateSearchResponse(data: unknown): BobbinSearchResponse { if (typeof data !== 'object' || data === null || !('hits' in data)) { throw new BobbinError('malformed-response', 'Bobbin returned invalid search results') } const result = data as Record<string, unknown> if (!Array.isArray(result.hits) || (result.cursor !== undefined && typeof result.cursor !== 'string')) { throw new BobbinError('malformed-response', 'Bobbin returned invalid search results') }
for (const hit of result.hits) { if ( typeof hit !== 'object' || hit === null || !is(v.resourceUriString(), hit.uri) || !is(v.nsidString(), hit.nsid) || (hit.cid !== undefined && !is(v.cidString(), hit.cid)) || typeof hit.score !== 'number' || !Number.isFinite(hit.score) || typeof hit.value !== 'object' || hit.value === null ) { throw new BobbinError('malformed-response', 'Bobbin returned an invalid search hit') } }
return result as unknown as BobbinSearchResponse}