// Whether the account's PDS serves permissioned spaces, which is what a // private repository is made of. // // `community.lexicon.service.describe` is the only way to ask. atproto // publishes no method list, so the alternative, calling a space method and // reading the failure, cannot tell a server without the feature apart from // one that is broken. A PDS that does not answer describe reads as serving // none, which is the right answer for every PDS without spaces anyway. // // A yes is kept for an hour, a no for five minutes. A server gains spaces by // redeploying, not by telling a page, and a stale no would keep telling the // owner they cannot make a private repository long after it turned on. import { account } from './git.js'; const DESCRIBE = 'community.lexicon.service.describe'; const TIMEOUT_MS = 5000; const YES_TTL_MS = 60 * 60 * 1000; const NO_TTL_MS = 5 * 60 * 1000; const KEY = 'git-ui:space-support'; /** The space methods a private repository needs, in the order a page uses them. */ const REQUIRED = [ 'com.atproto.space.createSpace', 'com.atproto.space.createRecord', ]; /** Every method name in a describe answer, or null when it is not one. */ async function fetchMethods() { let res; try { res = await fetch(`${account.pds}/xrpc/${DESCRIBE}`, { signal: AbortSignal.timeout(TIMEOUT_MS), }); } catch { return null; } if (!res.ok) return null; /** @type {{methods?: Array<{value?: unknown}>}|null} */ const body = await res.json().catch(() => null); if (!Array.isArray(body?.methods)) return null; return new Set( body.methods .map((entry) => entry.value) .filter((value) => typeof value === 'string'), ); } /** The probe that reads a cache in hand, then asks the PDS. */ /** @type {Promise|null} */ let pending = null; /** @type {boolean|null} */ let cached = null; let cachedAt = 0; /** * Whether the account's PDS serves the space methods a private repository * needs. One answer per PDS per visit, from the cache or a fresh probe. * @returns {Promise} */ export function spaceSupport() { if (pending) return pending; const now = Date.now(); const ttl = cached ? YES_TTL_MS : NO_TTL_MS; if (cached !== null && now - cachedAt < ttl) { return Promise.resolve(cached); } try { const stored = JSON.parse(localStorage.getItem(KEY) || 'null'); if (stored && typeof stored.supported === 'boolean') { const age = now - (stored.at || 0); if (age < (stored.supported ? YES_TTL_MS : NO_TTL_MS)) { cached = stored.supported; cachedAt = stored.at; return Promise.resolve(/** @type {boolean} */ (cached)); } } } catch { // Blocked or malformed storage reads as no cache. } const probing = fetchMethods() .then((methods) => { if (!methods) return false; return REQUIRED.every((name) => methods.has(name)); }) .then((supported) => { cached = supported; cachedAt = Date.now(); try { localStorage.setItem(KEY, JSON.stringify({ supported, at: cachedAt })); } catch { // A page that cannot write storage probes again next time. } return supported; }); void probing.finally(() => { pending = null; }); pending = probing; return probing; }