import { parseCanonicalResourceUri as partsOf } from "@atcute/lexicons/syntax"; import { Agent, fetch } from "undici"; import { keep } from "./record"; // node can't resolve the stack's names or trust its dev ca export const dispatcher = new Agent({ connect: { rejectUnauthorized: false, lookup: (_host, options, callback) => { const address = { address: "127.0.0.1", family: 4 }; if (options.all) callback(null, [address]); else callback(null, address.address, address.family); } } }); export interface Reply { status: number; body: any; } interface Options { method?: "GET" | "POST"; token?: string; json?: unknown; basic?: string; } export const request = async ( url: string, { method = "GET", token, json, basic }: Options = {} ): Promise => { const headers: Record = { ...(token ? { authorization: `Bearer ${token}` } : {}), ...(basic ? { authorization: `Basic ${Buffer.from(basic).toString("base64")}` } : {}), ...(json === undefined ? {} : { "content-type": "application/json" }) }; const body = json === undefined ? undefined : JSON.stringify(json); const started = Date.now(); const sent = { by: "node" as const, method, url, request: { headers, body } }; // without this a hung service holds the hop for undici's default of minutes const response = await fetch(url, { dispatcher, signal: AbortSignal.timeout(10_000), method, headers, body }).catch((error) => { const reason = error.cause?.code ?? error.cause ?? error.message; keep({ ...sent, ms: Date.now() - started, error: String(reason) }); // undici's "fetch failed" hides the real reason, which is on error.cause throw new Error(`${method} ${url}: ${reason}`); }); const text = await response.text(); keep({ ...sent, status: response.status, ms: Date.now() - started, response: { headers: Object.fromEntries(response.headers), body: text } }); return { status: response.status, body: parseBody(text) }; }; export const ok = (what: string, reply: Reply, status = 200) => { if (reply.status !== status) throw new Error(`${what} answered ${reply.status} ${JSON.stringify(reply.body)}`); return reply.body; }; // a failing service may answer with plain text, not json export const parseBody = (text: string): unknown => { try { return JSON.parse(text); } catch { return text; } }; // request() drops the headers, so this makes its own fetch export const contentTypeOf = async (url: string): Promise => { const response = await fetch(url, { dispatcher, signal: AbortSignal.timeout(10_000) }); await response.arrayBuffer(); return response.headers.get("content-type"); }; export const sleep = (ms: number) => new Promise((wake) => setTimeout(wake, ms)); // the knot limits per account and the pds on logins, so each try waits longer export const retry429 = async ( go: () => Promise, tries = 6, step = 300 ) => { for (let attempt = 1; ; attempt++) { const reply = await go(); if (reply.status !== 429 || attempt === tries) return reply; await sleep(step * attempt); } }; export const get = (url: string) => request(url); export const xrpc = (base: string, nsid: string, params: Record = {}) => `${base}/xrpc/${nsid}?${new URLSearchParams(params)}`; export interface Written { uri: string; cid: string; } export { partsOf }; export const selfUri = (did: string, nsid: string) => `at://${did}/${nsid}/self`; export const recordUrl = (base: string, uri: string) => { const { repo, collection, rkey } = partsOf(uri); return xrpc(base, "com.atproto.repo.getRecord", { repo, collection, rkey }); }; // stampedAt reads the time back out, so a sweep can spot leaked names export const stamp = () => `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`; export const stampedAt = (name: string) => { const time = /-([0-9a-z]{8})[0-9a-z]{3}$/.exec(name)?.[1]; return time ? parseInt(time, 36) : undefined; }; // most waits end within a few tries, so the pauses start short // a long poll already waited on the server, so it skips the pause export const until = async ( what: string, budget: number, check: () => Promise<[done: boolean, saw: string, value?: T]>, { longPoll = false } = {} ) => { const deadline = Date.now() + budget; for (let pause = 25; ; pause = Math.min(pause * 2, 200)) { const asked = Date.now(); const [done, saw, value] = await check(); if (done) return value as T; if (Date.now() > deadline) throw new Error(`${what}, gave up after ${budget / 1000}s: ${saw}`); if (!longPoll || Date.now() - asked < 1_000) await sleep(pause); } };