import { Client, simpleFetchHandler } from "@atcute/client";
import type { OAuthUserAgent } from "@atcute/oauth-browser-client";
import { isNsid } from "@atcute/lexicons/syntax";
import { mintServiceAuth, serviceDidForHost } from "#lib/auth/agent.ts";
import { didOf, nsidOf, type Nsid } from "#lib/api/syntax.ts";
import { normalizeHandle } from "#lib/handle.ts";
import type { Fetch } from "#lib/fetch.ts";
export { ClientResponseError, isXRPCErrorPayload, ok } from "@atcute/client";
export type { XRPCErrorPayload } from "@atcute/client";
// generated lexicons register ambient types; don't import the barrel at runtime.
export interface BobbinContext {
readonly xrpc: Client;
readonly serviceUrl: string;
readonly fetch: Fetch;
}
export interface CreateBobbinOptions {
serviceUrl: string;
fetch?: Fetch;
/** when set, every xrpc request carries a freshly minted service-auth jwt */
agent?: OAuthUserAgent;
/** off for bodies with no handles that must keep streaming, see withNormalizedHandles */
normalizeHandles?: boolean;
}
export const nsidFromUrl = (input: RequestInfo | URL): Nsid | null => {
const href = input instanceof Request ? input.url : String(input);
let pathname: string;
try {
pathname = new URL(href).pathname;
} catch {
return null;
}
const [, prefix, nsid, ...rest] = pathname.split("/");
return prefix === "xrpc" && nsid && rest.length === 0 && isNsid(nsid) ? nsidOf(nsid) : null;
};
// pocket keys stored preferences by the token's `aud`
export const PREFERENCES_AUD = didOf("did:web:tangled.org");
export const PREFERENCES_LXMS: readonly Nsid[] = [
nsidOf("com.bad-example.pocket.getPreferences"),
nsidOf("com.bad-example.pocket.putPreferences")
];
export const withServiceAuth = (base: Fetch, agent: OAuthUserAgent, origin: string): Fetch => {
return async (input, init) => {
const lxm = nsidFromUrl(input);
if (!lxm) return base(input, init);
const aud = PREFERENCES_LXMS.includes(lxm)
? PREFERENCES_AUD
: serviceDidForHost(new URL(origin).host);
const token = await mintServiceAuth(agent, { aud, lxm });
const headers = new Headers(init?.headers);
headers.set("authorization", `Bearer ${token}`);
return base(input, { ...init, headers });
};
};
// the handles the ui shows come out of bobbin as a `handle` field somewhere in a json
// body, so normalizing them here covers the views at once. it buffers the body, so
// non-json (patches, archives, blobs) passes through, and git reads opt out
export const withNormalizedHandles = (base: Fetch): Fetch => {
return async (input, init) => {
const response = await base(input, init);
if (!response.headers.get("content-type")?.includes("application/json")) return response;
const text = await response.text();
if (!text.includes('"handle"')) return new Response(text, response);
const body = JSON.stringify(
JSON.parse(text, (key, value: unknown) =>
key === "handle" && typeof value === "string" ? normalizeHandle(value) : value
)
);
// the body is decoded and rewritten, so the wire's encoding and length no longer apply
const headers = new Headers(response.headers);
headers.delete("content-encoding");
headers.delete("content-length");
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers
});
};
};
export const createBobbinClient = ({
serviceUrl,
fetch,
agent,
normalizeHandles = true
}: CreateBobbinOptions): BobbinContext => {
const origin = serviceUrl.replace(/\/+$/, "");
const rawFetch = fetch ?? globalThis.fetch;
const baseFetch = normalizeHandles ? withNormalizedHandles(rawFetch) : rawFetch;
const boundFetch = agent ? withServiceAuth(baseFetch, agent, origin) : baseFetch;
const xrpc = new Client({
handler: simpleFetchHandler({
service: origin,
fetch: boundFetch as typeof globalThis.fetch
})
});
return { xrpc, serviceUrl: origin, fetch: boundFetch };
};
export type QueryValue = string | number | boolean | readonly (string | number)[] | undefined;
export interface XrpcRequestInit {
signal?: AbortSignal;
headers?: HeadersInit;
}