import { ClientResponseError, isXRPCErrorPayload, type XRPCErrorPayload } from "@atcute/client"; import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; import { createBobbinClient, type BobbinContext, type QueryValue, type XrpcRequestInit } from "#lib/api/client.ts"; import { hostForServiceDid } from "#lib/auth/agent.ts"; import { xrpcPathOf, type XrpcPath } from "#lib/api/syntax.ts"; export const serviceUrlFor = (host: string): string => /^[a-z][a-z0-9+.-]*:\/\//i.test(host) ? host.replace(/\/+$/, "") : `https://${host}`; export const serviceClient = (agent: OAuthUserAgent, host: string): BobbinContext => createBobbinClient({ serviceUrl: serviceUrlFor(host), agent }); const BARE_HOST_PATTERN = /^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?::[0-9]{1,5})?$/i; // a last label of digits or 0x.. is an ipv4 literal to the url parser (2130706433, 0x7f.1, 017700000001) const NUMERIC_LABEL = /^(?:[0-9]+|0x[0-9a-f]*)$/i; const INTERNAL_SUFFIXES = [".localhost", ".local", ".internal"]; // web fetches these hosts server side, so anything that names this machine or its network is out. // dev allows them because the local stack runs on loopback const isPublicName = (name: string): boolean => { const lower = name.toLowerCase(); const labels = lower.split("."); if (labels.length < 2 || NUMERIC_LABEL.test(labels[labels.length - 1])) return false; return !INTERNAL_SUFFIXES.some((suffix) => lower.endsWith(suffix)); }; const isBareHost = (value: string): boolean => { if (!value || !BARE_HOST_PATTERN.test(value)) return false; const colon = value.indexOf(":"); if (colon !== -1) { const port = Number(value.slice(colon + 1)); if (port < 1 || port > 65535) return false; } return import.meta.env.DEV || isPublicName(colon === -1 ? value : value.slice(0, colon)); }; export const hostForKnot = (knot: string): string => { if (knot.startsWith("did:web:")) { if (knot.slice("did:web:".length).includes(":")) { throw new Error(`invalid knot identifier: ${knot}`); } const host = hostForServiceDid(knot); if (host && isBareHost(host)) return host; throw new Error(`invalid knot identifier: ${knot}`); } if (isBareHost(knot)) return knot; throw new Error(`invalid knot identifier: ${knot}`); }; // manifest values are untrusted, so junk returns undefined instead of throwing export const tryHostForKnot = (knot: string | undefined): string | undefined => { if (!knot) return undefined; try { return hostForKnot(knot); } catch { return undefined; } }; export const buildUrl = ( origin: string, path: XrpcPath, params?: Record ): URL => { const url = new URL(`/xrpc/${path}`, `${origin}/`); if (params) { for (const [key, value] of Object.entries(params)) { if (value === undefined) continue; if (Array.isArray(value)) { for (const item of value) url.searchParams.append(key, String(item)); } else { url.searchParams.set(key, String(value)); } } } return url; }; export const toResponseError = async (response: Response): Promise => { let data: XRPCErrorPayload = { error: "XRPCError", message: response.statusText }; try { const body: unknown = await response.json(); if (isXRPCErrorPayload(body)) data = body; } catch { // keeps the status-line fallback for non-json bodies } return new ClientResponseError({ status: response.status, headers: response.headers, data }); }; export const jsonGet = async ( ctx: BobbinContext, nsid: string, params?: Record, init?: XrpcRequestInit ): Promise => { const response = await ctx.fetch(buildUrl(ctx.serviceUrl, xrpcPathOf(nsid), params), { headers: { accept: "application/json", ...init?.headers }, signal: init?.signal }); if (!response.ok) throw await toResponseError(response); if (typeof response.text === "function") { const text = await response.text(); return (text.length > 0 ? JSON.parse(text) : undefined) as T; } return (await response.json()) as T; }; export const jsonPost = async ( ctx: BobbinContext, nsid: string, body: unknown, init?: XrpcRequestInit ): Promise => { const response = await ctx.fetch(buildUrl(ctx.serviceUrl, xrpcPathOf(nsid)), { method: "POST", headers: { ...(body !== undefined ? { "content-type": "application/json" } : {}), accept: "application/json", ...init?.headers }, body: body !== undefined ? JSON.stringify(body) : undefined, signal: init?.signal }); if (!response.ok) throw await toResponseError(response); if (typeof response.text === "function") { const text = await response.text(); return (text.length > 0 ? JSON.parse(text) : undefined) as T; } return (await response.json()) as T; }; export const rawGet = async ( ctx: BobbinContext, nsid: string, params?: Record, init?: XrpcRequestInit ): Promise => { const response = await ctx.fetch(buildUrl(ctx.serviceUrl, xrpcPathOf(nsid), params), { headers: init?.headers, signal: init?.signal }); if (!response.ok) throw await toResponseError(response); return response; };