import { 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 { uri: string cid?: string value: T } /** One cursor-based page returned by an indexed Bobbin query. */ export interface CursorPage { 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 } 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 * 1024 export 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 export type RepositoryArchiveOptions = RequestOptions & { format?: RepositoryArchiveFormat ifModifiedSince?: string ifNoneMatch?: string prefix?: string range?: string ref?: string } export type RepositoryDownload = { body: ReadableStream | 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 & { 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 { 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 = | { ok: true; data: T; status: number; headers: Headers } | { ok: false; data: { error: string; message?: string }; status: number; headers: Headers } type SuccessData = 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 }> } /** * 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 { 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 { 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> { 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 { 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> { 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> { 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[]> { 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>> { 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> { 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> { 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> { 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 { 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 { 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 { 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 { const request = (nsid: string, call: (signal: AbortSignal) => Promise>) => 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 { 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>>( params: SearchParams, schemas: TSchemas, options: RequestOptions = {}, ): Promise>>> { 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>( nsid: string, parameters: unknown, options: RequestOptions, staleTimeMs: number, request: (signal: AbortSignal) => Promise, ): Promise> { 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( kind: ActorActivityKind, parameters: Record, options: RequestOptions, schema: TSchema | undefined, request: ( signal: AbortSignal, ) => Promise>, ): Promise> { 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) : (item as Record), })), cursor: data.cursor, } } async #request>( request: () => Promise, ): Promise> { try { const response = await request() if (!response.ok) { throw errorFromResponse(response) } return response.data as SuccessData } catch (error) { throw errorFromException(error) } } async #getRepositoryPatch(url: string, options: RequestOptions): Promise { 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 { 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( schema: TSchema, value: unknown, context: string, ): InferInput { 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( view: { uri: string; cid?: string; value: unknown }, schema: TSchema, context: string, ): ValidatedRecordView> { 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) } 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 { 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 { 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 { const record = optionalRecord(value) if (!record) throw malformed(context) return record } function optionalRecord(value: unknown): Record | undefined { return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : 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 { 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 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 }