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<KnotOrigin, { at: number; capabilities: readonly Capability[] | null }>();
const asking = new Map<KnotOrigin, Promise<readonly Capability[] | null>>();
const capabilitiesOf = (body: unknown): readonly Capability[] | null => {
if (typeof body !== "object" || body === null) return null;
const { capabilities } = body as Record<string, unknown>;
if (!Array.isArray(capabilities)) return null;
return capabilities.every((token) => typeof token === "string")
? capabilities.map(capabilityOf)
: null;
};
const describeKnot = async (knot: KnotOrigin): Promise<readonly Capability[] | null> => {
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<readonly Capability[] | null> => {
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<KnotOrigin | undefined> => {
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<KnotOrigin | undefined> =>
bobbinUrl
? readTarget(createBobbinClient({ serviceUrl: bobbinUrl }), repoDid, fosterUrl)
: Promise.resolve(undefined);