import type { ResourceUri } from '@atcute/lexicons/syntax'; import { normalizeServiceUrl } from './client'; import { getRouteKnotRequestsThroughAppview, getTangledAppviewService, } from '../settings'; export interface AppviewRecordView { uri: ResourceUri; cid?: string | null; value: T; } export interface AppviewListResponse { items: Array>; cursor?: string | null; } export interface AppviewBulkResponse { items: Array>; } export interface AppviewStatefulRecordView extends AppviewRecordView { state?: string; stateUpdatedAt?: string; commentCount?: number; } export interface AppviewStatefulListResponse { items: Array>; cursor?: string | null; } export interface AppviewSearchHit extends AppviewRecordView { nsid: string; score: number; } export interface AppviewSearchResponse { hits: Array>; cursor?: string | null; } interface AppviewCountResponse { count: number; distinctAuthors?: number; distinct_authors?: number; } export type AppviewParamValue = string | number | boolean | null | undefined; export class AppviewResponseError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = 'AppviewResponseError'; this.status = status; } } export class AppviewUnavailableError extends Error { readonly cause: unknown; constructor(cause: unknown) { super(cause instanceof Error ? cause.message : 'Appview unavailable'); this.name = 'AppviewUnavailableError'; this.cause = cause; } } export const appviewJson = async ( nsid: string, params: Record = {}, signal?: AbortSignal, ): Promise => { const url = new URL(`/xrpc/${nsid}`, normalizeServiceUrl(getTangledAppviewService())); for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null) { url.searchParams.set(key, String(value)); } } let response: Response; try { response = await fetch(url, { headers: { accept: 'application/json', }, signal, }); } catch (cause) { throw new AppviewUnavailableError(cause); } if (!response.ok) { let message = `${response.status} ${response.statusText}`.trim(); try { const body = (await response.json()) as { message?: unknown; error?: unknown }; const bodyMessage = typeof body.message === 'string' ? body.message : body.error; if (typeof bodyMessage === 'string') { message = bodyMessage; } } catch { // Keep the HTTP status message when the appview does not return JSON. } throw new AppviewResponseError(response.status, `Appview ${nsid} failed (${response.status}): ${message}`); } return (await response.json()) as T; }; export const appviewJsonUrl = async (url: URL, signal?: AbortSignal): Promise => { let response: Response; try { response = await fetch(url, { headers: { accept: 'application/json', }, signal, }); } catch (cause) { throw new AppviewUnavailableError(cause); } if (!response.ok) { let message = `${response.status} ${response.statusText}`.trim(); try { const body = (await response.json()) as { message?: unknown; error?: unknown }; const bodyMessage = typeof body.message === 'string' ? body.message : body.error; if (typeof bodyMessage === 'string') { message = bodyMessage; } } catch { // Keep the HTTP status message when the appview does not return JSON. } throw new AppviewResponseError(response.status, `Appview ${url.pathname.slice('/xrpc/'.length)} failed (${response.status}): ${message}`); } return (await response.json()) as T; }; export const appviewFallback = async (primary: () => Promise, fallback: () => Promise): Promise => { try { return await primary(); } catch (cause) { if (!(cause instanceof AppviewUnavailableError)) { throw cause; } return fallback(); } }; export const listAllAppviewRecords = async ( nsid: string, subject: string, extraParams: Record = {}, ): Promise>> => { const records: Array> = []; let cursor: string | undefined; do { const page = await appviewJson>(nsid, { subject, limit: 100, cursor, ...extraParams, }); records.push(...page.items); cursor = page.cursor ?? undefined; } while (cursor); return records; }; export const appviewBulkRecords = async ( nsid: string, paramName: string, uris: ResourceUri[], ): Promise>> => { if (uris.length === 0) { return []; } const records: Array> = []; for (let index = 0; index < uris.length; index += 50) { const url = new URL(`/xrpc/${nsid}`, normalizeServiceUrl(getTangledAppviewService())); for (const uri of uris.slice(index, index + 50)) { url.searchParams.append(paramName, uri); } const page = await appviewJsonUrl>(url); records.push(...page.items); } return records; }; export const getAppviewRecordCount = async (nsid: string, subject: string): Promise => { const count = await appviewJson(nsid, { subject }); return count.count; }; export const getDistinctAppviewRecordCount = async (nsid: string, subject: string): Promise => { const count = await appviewJson(nsid, { subject }); return count.distinctAuthors ?? count.distinct_authors ?? count.count; }; export const getRepoViaAppview = async ( nsid: string, repo: { record: { uri: string } }, params: Record = {}, ): Promise => appviewJson(nsid, { repo: repo.record.uri, ...params, }); export const appviewOptionalKnotRequest = async ( appview: () => Promise, direct: () => Promise, ): Promise => { if (!getRouteKnotRequestsThroughAppview()) { return direct(); } return appviewFallback(appview, direct); };