import type { ResourceUri } from '@atcute/lexicons/syntax';
import { normalizeServiceUrl } from './client';
import {
getRouteKnotRequestsThroughAppview,
getTangledAppviewService,
getForcePds,
} from '../settings';
export interface AppviewRecordView<T> {
uri: ResourceUri;
cid?: string | null;
value: T;
}
export interface AppviewListResponse<T> {
items: Array<AppviewRecordView<T>>;
cursor?: string | null;
}
export interface AppviewBulkResponse<T> {
items: Array<AppviewRecordView<T>>;
}
export interface AppviewStatefulRecordView<T> extends AppviewRecordView<T> {
state?: string;
stateUpdatedAt?: string;
commentCount?: number;
}
export interface AppviewStatefulListResponse<T> {
items: Array<AppviewStatefulRecordView<T>>;
cursor?: string | null;
}
export interface AppviewSearchHit<T> extends AppviewRecordView<T> {
nsid: string;
score: number;
}
export interface AppviewSearchResponse<T> {
hits: Array<AppviewSearchHit<T>>;
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 <T>(
nsid: string,
params: Record<string, AppviewParamValue> = {},
signal?: AbortSignal,
): Promise<T> => {
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 <T>(url: URL, signal?: AbortSignal): Promise<T> => {
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 <T>(primary: () => Promise<T>, fallback: () => Promise<T>): Promise<T> => {
if (getForcePds()) {
return fallback();
}
try {
return await primary();
} catch (cause) {
if (!(cause instanceof AppviewUnavailableError)) {
throw cause;
}
return fallback();
}
};
export const listAllAppviewRecords = async <T>(
nsid: string,
subject: string,
extraParams: Record<string, AppviewParamValue> = {},
): Promise<Array<AppviewRecordView<T>>> => {
const records: Array<AppviewRecordView<T>> = [];
let cursor: string | undefined;
do {
const page = await appviewJson<AppviewListResponse<T>>(nsid, {
subject,
limit: 100,
cursor,
...extraParams,
});
records.push(...page.items);
cursor = page.cursor ?? undefined;
} while (cursor);
return records;
};
export const appviewBulkRecords = async <T>(
nsid: string,
paramName: string,
uris: ResourceUri[],
): Promise<Array<AppviewRecordView<T>>> => {
if (uris.length === 0) {
return [];
}
const records: Array<AppviewRecordView<T>> = [];
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<AppviewBulkResponse<T>>(url);
records.push(...page.items);
}
return records;
};
export const getAppviewRecordCount = async (nsid: string, subject: string): Promise<number> => {
const count = await appviewJson<AppviewCountResponse>(nsid, { subject });
return count.count;
};
export const getDistinctAppviewRecordCount = async (nsid: string, subject: string): Promise<number> => {
const count = await appviewJson<AppviewCountResponse>(nsid, { subject });
return count.distinctAuthors ?? count.distinct_authors ?? count.count;
};
export const getRepoViaAppview = async <T>(
nsid: string,
repo: { record: { uri: string } },
params: Record<string, AppviewParamValue> = {},
): Promise<T> =>
appviewJson<T>(nsid, {
repo: repo.record.uri,
...params,
});
export const appviewOptionalKnotRequest = async <T>(
appview: () => Promise<T>,
direct: () => Promise<T>,
): Promise<T> => {
if (!getRouteKnotRequestsThroughAppview()) {
return direct();
}
return appviewFallback(appview, direct);
};