Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462import { HANDLE_RESOLVER_SERVICE_URL, PLC_DIRECTORY_URL } from "/js/config.js";import { fetchWithTimeout, isValidDid } from "/js/utils.js";import { Slingshot } from "/js/slingshot.js";
const PDS_SERVICE_ID = "#atproto_pds";
export class HandleNotFoundError extends Error { constructor(message) { super(message); this.name = "HandleNotFoundError"; }}
export function findServiceEndpointInDidDoc( didDoc, serviceId = PDS_SERVICE_ID,) { const service = didDoc?.service?.find((s) => s.id === serviceId); return service?.serviceEndpoint ?? null;}
export function getServiceEndpointFromDidDoc( didDoc, serviceId = PDS_SERVICE_ID,) { const endpoint = findServiceEndpointInDidDoc(didDoc, serviceId); if (!endpoint) { throw new Error( `No ${serviceId} service found in DID doc ${JSON.stringify(didDoc)}`, ); } return endpoint;}
export function didDocReferencesHandle(didDoc, handle) { const atHandle = "at://" + handle; const aliases = didDoc.alsoKnownAs ?? []; return aliases.includes(atHandle);}
const RESOLVE_TIMEOUT_MS = 5000;
export async function resolveHandle(handle) { const params = new URLSearchParams({ handle, }); const res = await fetchWithTimeout( `${HANDLE_RESOLVER_SERVICE_URL}/xrpc/com.atproto.identity.resolveHandle?` + params.toString(), { timeoutMs: RESOLVE_TIMEOUT_MS, label: `resolveHandle "${handle}"` }, ); if (res.status === 400) return null; if (!res.ok) { throw new Error(`resolveHandle "${handle}": HTTP ${res.status}`); } const data = await res.json(); return isValidDid(data?.did) ? data.did : null;}
// Returns null for a DID that isn't registered (or has been tombstoned),// throws otherwiseexport async function resolveDid(did) { let url; if (did.startsWith("did:plc:")) { url = `${PLC_DIRECTORY_URL}/${encodeURIComponent(did)}`; } else if (did.startsWith("did:web:")) { url = `https://${did.split(":")[2]}/.well-known/did.json`; } else { throw new Error(`Unsupported DID: ${did}`); } const res = await fetchWithTimeout(url, { timeoutMs: RESOLVE_TIMEOUT_MS, label: `resolveDid "${did}"`, }); if (res.status === 404 || res.status === 410) return null; if (!res.ok) { throw new Error(`resolveDid "${did}": HTTP ${res.status}`); } return await res.json();}
export async function resolveIdentity(handle) { const did = await resolveHandle(handle); if (!did) return null; const didDoc = await resolveDid(did); if (!didDoc) return null; if (!didDocReferencesHandle(didDoc, handle)) { throw new Error(`DID doc for ${did} does not reference handle: ${handle}`); } return { did, didDoc };}
let slingshotClient = null;
function getSlingshot() { slingshotClient ??= new Slingshot(); return slingshotClient;}
const DEFAULT_HANDLE_PROVIDERS = [ { name: "bluesky", resolve: (handle) => resolveHandle(handle) }, { name: "slingshot", resolve: (handle) => getSlingshot().resolveHandle(handle), },];
async function resolveHandleWithFallback(handle, providers) { let lastError = null; for (const provider of providers) { try { return await provider.resolve(handle); } catch (error) { lastError = error; console.debug( `[IdentityResolver] provider "${provider.name}" could not resolve "${handle}"`, error, ); } } throw lastError ?? new Error(`resolveHandle: no providers for "${handle}"`);}
function miniDocMatchesIdentifier(miniDoc, identifier) { if (isValidDid(identifier)) { return miniDoc.did === identifier; } return miniDoc.handle?.toLowerCase() === identifier.toLowerCase();}
// Resolves a handle or DID to its DID and PDS endpoint.export async function resolveIdentityEndpoint(handleOrDid) { try { const miniDoc = await getSlingshot().resolveMiniDoc(handleOrDid); if (miniDocMatchesIdentifier(miniDoc, handleOrDid)) { return { did: miniDoc.did, pds: miniDoc.pds }; } console.debug( `[resolveIdentityEndpoint] slingshot returned a mismatched identity for "${handleOrDid}"`, miniDoc, ); } catch (error) { console.debug( `[resolveIdentityEndpoint] slingshot could not resolve "${handleOrDid}"`, error, ); } if (isValidDid(handleOrDid)) { const didDoc = await resolveDid(handleOrDid); const pds = findServiceEndpointInDidDoc(didDoc); return pds ? { did: handleOrDid, pds } : null; } const result = await resolveIdentity(handleOrDid); if (!result) return null; const pds = findServiceEndpointInDidDoc(result.didDoc); return pds ? { did: result.did, pds } : null;}
const HANDLE_NOT_FOUND_TTL_MS = 30_000;const ENDPOINT_TTL_MS = 300_000;
export class IdentityResolver { constructor({ providers = DEFAULT_HANDLE_PROVIDERS, notFoundTtlMs = HANDLE_NOT_FOUND_TTL_MS, endpointTtlMs = ENDPOINT_TTL_MS, } = {}) { this.providers = providers; this.notFoundTtlMs = notFoundTtlMs; this.endpointTtlMs = endpointTtlMs; this.handleToDidMap = new Map(); this.endpointCache = new Map(); this.notFoundAt = new Map(); this.inFlight = new Map(); this.endpointInFlight = new Map(); }
_isNotFound(identifier) { const notFoundAt = this.notFoundAt.get(identifier); if (notFoundAt == null) return false; if (Date.now() - notFoundAt < this.notFoundTtlMs) return true; this.notFoundAt.delete(identifier); return false; }
_dedupe(inFlight, identifier, start) { const existing = inFlight.get(identifier); if (existing) return existing; const resolution = start().finally(() => inFlight.delete(identifier)); inFlight.set(identifier, resolution); return resolution; }
async resolveHandle(handle) { if (this.handleToDidMap.has(handle)) { return this.handleToDidMap.get(handle); } if (this._isNotFound(handle)) return null; return this._dedupe(this.inFlight, handle, () => { console.debug("[IdentityResolver] Resolving handle", handle); return resolveHandleWithFallback(handle, this.providers).then((did) => { if (did) { this.handleToDidMap.set(handle, did); } else { this.notFoundAt.set(handle, Date.now()); } return did; }); }); }
async resolveEndpoint(handleOrDid) { const cached = this.endpointCache.get(handleOrDid); if (cached && Date.now() - cached.at < this.endpointTtlMs) { return cached.result; } if (this._isNotFound(handleOrDid)) return null; return this._dedupe(this.endpointInFlight, handleOrDid, () => { console.debug("[IdentityResolver] Resolving endpoint", handleOrDid); return resolveIdentityEndpoint(handleOrDid).then((result) => { if (result) { this.endpointCache.set(handleOrDid, { at: Date.now(), result }); if (!isValidDid(handleOrDid)) { this.handleToDidMap.set(handleOrDid, result.did); } } else { this.notFoundAt.set(handleOrDid, Date.now()); } return result; }); }); }
setDidForHandle(handle, did) { this.notFoundAt.delete(handle); this.endpointCache.delete(handle); this.handleToDidMap.set(handle, did); }}
export async function resolveDidFromHandleOrDid(handleOrDid, identityResolver) { if (handleOrDid.startsWith("did:")) { return handleOrDid; } const did = await identityResolver.resolveHandle(handleOrDid); if (!did) { throw new HandleNotFoundError("DID not found for handle: " + handleOrDid); } return did;}
export async function getServiceEndpointForHandle(handle, resolver) { const result = await resolver.resolveEndpoint(handle); if (!result) { throw new HandleNotFoundError("DID not found for handle: " + handle); } return result.pds;}
const TID_ALPHABET = "234567abcdefghijklmnopqrstuvwxyz";
let lastTimestamp = 0n;
// Will return null if the rkey is not a TID.export function getTimestampFromRkey(rkey) { const noDashes = rkey.replaceAll("-", ""); if (noDashes.length !== 13) { return null; } let value = 0; for (const c of noDashes.slice(0, 11)) { value = value * 32 + TID_ALPHABET.indexOf(c); } return value;}
export function generateTid() { const nowMicroseconds = BigInt(Date.now()) * 1000n; const timestamp = nowMicroseconds <= lastTimestamp ? lastTimestamp + 1n : nowMicroseconds; lastTimestamp = timestamp; const clockSeq = BigInt(Math.floor(Math.random() * 1024)); let tid = (timestamp << 10n) | clockSeq; let result = ""; for (let i = 0; i < 13; i++) { const remainder = tid % 32n; result = TID_ALPHABET[Number(remainder)] + result; tid = tid / 32n; } return result;}
// Note: the DAG-CBOR encoder was written fully by Claude.// Unit tested against reference implementation outputs// for additional confidence.
const CID_BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567";
function base32Encode(bytes) { let result = ""; let buffer = 0; let bitCount = 0; for (const byte of bytes) { buffer = (buffer << 8) | byte; bitCount += 8; while (bitCount >= 5) { result += CID_BASE32_ALPHABET[(buffer >>> (bitCount - 5)) & 31]; bitCount -= 5; } } if (bitCount > 0) { result += CID_BASE32_ALPHABET[(buffer << (5 - bitCount)) & 31]; } return result;}
function base32Decode(str) { const bytes = []; let buffer = 0; let bitCount = 0; for (const char of str) { const index = CID_BASE32_ALPHABET.indexOf(char); if (index === -1) { throw new Error(`Invalid base32 character: ${char}`); } buffer = (buffer << 5) | index; bitCount += 5; if (bitCount >= 8) { bytes.push((buffer >>> (bitCount - 8)) & 0xff); bitCount -= 8; } } return new Uint8Array(bytes);}
function cborTypeAndArg(bytes, majorType, arg) { const majorBits = majorType << 5; if (arg < 24) { bytes.push(majorBits | arg); } else if (arg <= 0xff) { bytes.push(majorBits | 24, arg); } else if (arg <= 0xffff) { bytes.push(majorBits | 25, arg >>> 8, arg & 0xff); } else if (arg <= 0xffffffff) { bytes.push( majorBits | 26, (arg >>> 24) & 0xff, (arg >>> 16) & 0xff, (arg >>> 8) & 0xff, arg & 0xff, ); } else { const high = Math.floor(arg / 0x100000000); const low = arg % 0x100000000; bytes.push( majorBits | 27, (high >>> 24) & 0xff, (high >>> 16) & 0xff, (high >>> 8) & 0xff, high & 0xff, (low >>> 24) & 0xff, (low >>> 16) & 0xff, (low >>> 8) & 0xff, low & 0xff, ); }}
function isPlainObject(value) { if (typeof value !== "object" || value === null) { return false; } const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null;}
function isCidLink(value) { return ( isPlainObject(value) && typeof value.$link === "string" && Object.keys(value).length === 1 );}
// Covers only the value types that appear in atproto records// (strings, safe integers, booleans, null, arrays,// string-keyed plain objects, and CID references).function cborEncodeValue(bytes, value) { if (typeof value === "string") { const encoded = new TextEncoder().encode(value); cborTypeAndArg(bytes, 3, encoded.length); bytes.push(...encoded); } else if (typeof value === "number") { if (!Number.isSafeInteger(value)) { throw new Error(`Cannot CBOR-encode non-integer number: ${value}`); } if (value >= 0) { cborTypeAndArg(bytes, 0, value); } else { cborTypeAndArg(bytes, 1, -value - 1); } } else if (typeof value === "boolean") { bytes.push(value ? 0xf5 : 0xf4); } else if (value === null) { bytes.push(0xf6); } else if (Array.isArray(value)) { cborTypeAndArg(bytes, 4, value.length); for (const element of value) { cborEncodeValue(bytes, element); } } else if (isCidLink(value)) { if (!value.$link.startsWith("b")) { throw new Error( `Unsupported CID multibase (expected base32): ${value.$link}`, ); } const cidBytes = base32Decode(value.$link.slice(1)); bytes.push(0xd8, 42); cborTypeAndArg(bytes, 2, cidBytes.length + 1); bytes.push(0); bytes.push(...cidBytes); } else if (isPlainObject(value)) { const textEncoder = new TextEncoder(); const entries = Object.entries(value) .filter(([, entryValue]) => entryValue !== undefined) .map(([key, entryValue]) => ({ keyBytes: textEncoder.encode(key), value: entryValue, })) .sort((a, b) => { if (a.keyBytes.length !== b.keyBytes.length) { return a.keyBytes.length - b.keyBytes.length; } for (let i = 0; i < a.keyBytes.length; i++) { if (a.keyBytes[i] !== b.keyBytes[i]) { return a.keyBytes[i] - b.keyBytes[i]; } } return 0; }); cborTypeAndArg(bytes, 5, entries.length); for (const entry of entries) { cborTypeAndArg(bytes, 3, entry.keyBytes.length); bytes.push(...entry.keyBytes); cborEncodeValue(bytes, entry.value); } } else { throw new Error(`Cannot CBOR-encode value of type ${typeof value}`); }}
// CIDv1 (0x01), dag-cbor codec (0x71), sha-256 (0x12), 32-byte digest (0x20).const CID_HEADER = [0x01, 0x71, 0x12, 0x20];
export async function computeRecordCid(record) { const encoded = []; cborEncodeValue(encoded, record); const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(encoded)); const cidBytes = new Uint8Array([...CID_HEADER, ...new Uint8Array(digest)]); return "b" + base32Encode(cidBytes);}