import { createBobbinClient, type BobbinContext } from "#lib/api/client.ts"; import { resolveRepoKnot } from "#lib/api/repo.ts"; import { capabilityOf, type Capability, type Did, type KnotOrigin } from "#lib/api/syntax.ts"; export const SOCIAL_WRITES_CAPABILITY = capabilityOf("knot-social-writes"); const DESCRIBE_KNOT_NSID = "org.tangled.knot.describeKnot"; export const CAPABILITY_TTL_MS = 5 * 60_000; const FAILED_PROBE_TTL_MS = 30_000; const PROBE_TIMEOUT_MS = 10_000; const probes = new Map(); const asking = new Map>(); const capabilitiesOf = (body: unknown): readonly Capability[] | null => { if (typeof body !== "object" || body === null) return null; const { capabilities } = body as Record; if (!Array.isArray(capabilities)) return null; return capabilities.every((token) => typeof token === "string") ? capabilities.map(capabilityOf) : null; }; const describeKnot = async (knot: KnotOrigin): Promise => { const response = await fetch(`https://${knot}/xrpc/${DESCRIBE_KNOT_NSID}`, { headers: { accept: "application/json" }, signal: AbortSignal.timeout(PROBE_TIMEOUT_MS) }); if (response.status === 404) return []; if (!response.ok) return null; return capabilitiesOf(await response.json().catch(() => null)); }; export const knotCapabilities = async ( knot: KnotOrigin, now: () => number = Date.now ): Promise => { const cached = probes.get(knot); const ttl = cached?.capabilities === null ? FAILED_PROBE_TTL_MS : CAPABILITY_TTL_MS; if (cached && now() - cached.at < ttl) return cached.capabilities; const pending = asking.get(knot); if (pending) return pending; const probe = describeKnot(knot) .catch(() => null) .finally(() => asking.delete(knot)); asking.set(knot, probe); const capabilities = await probe; probes.set(knot, { at: now(), capabilities }); return capabilities; }; export const readTarget = async ( ctx: BobbinContext, repoDid: Did, fosterUrl?: KnotOrigin | "" ): Promise => { const knot = await resolveRepoKnot(ctx, repoDid); if (!knot) return undefined; const capabilities = await knotCapabilities(knot); return capabilities === null || capabilities.includes(SOCIAL_WRITES_CAPABILITY) ? knot : fosterUrl || knot; }; export const readTargetFor = ( bobbinUrl: string | undefined, repoDid: Did, fosterUrl?: KnotOrigin | "" ): Promise => bobbinUrl ? readTarget(createBobbinClient({ serviceUrl: bobbinUrl }), repoDid, fosterUrl) : Promise.resolve(undefined);