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 SCHEME_PREFIX = /^[a-z][a-z0-9+.-]*:\/\//i;
export const serviceUrlFor = (host: string): string =>
SCHEME_PREFIX.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<string, QueryValue>
): 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<ClientResponseError> => {
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 <T>(
ctx: BobbinContext,
nsid: string,
params?: Record<string, QueryValue>,
init?: XrpcRequestInit
): Promise<T> => {
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 <T>(
ctx: BobbinContext,
nsid: string,
body: unknown,
init?: XrpcRequestInit
): Promise<T> => {
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<string, QueryValue>,
init?: XrpcRequestInit
): Promise<Response> => {
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;
};