Coves frontend - a photon fork
1// XRPC transport layer for ATProto lexicon calls.
2
3export class XrpcError extends Error {
4 constructor(
5 public status: number,
6 public errorName: string,
7 message: string,
8 ) {
9 super(message)
10 this.name = 'XrpcError'
11 }
12}
13
14interface XrpcClientOptions {
15 fetchFn: typeof fetch
16 baseUrl: string
17}
18
19export class XrpcClient {
20 readonly #fetchFn: typeof fetch
21 readonly #baseUrl: string
22
23 constructor(options: XrpcClientOptions) {
24 this.#fetchFn = options.fetchFn
25 this.#baseUrl = options.baseUrl
26 }
27
28 async query<P, R>(nsid: string, params?: P): Promise<R> {
29 const url = new URL(`/xrpc/${nsid}`, this.#baseUrl)
30
31 if (params) {
32 const searchParams = new URLSearchParams()
33 for (const [key, value] of Object.entries(
34 params as Record<string, unknown>,
35 )) {
36 if (value === undefined || value === null) continue
37 searchParams.set(key, String(value))
38 }
39 url.search = searchParams.toString()
40 }
41
42 const res = await this.#fetchFn(url.toString())
43
44 if (!res.ok) {
45 throw await this.#parseError(res)
46 }
47
48 try {
49 return (await res.json()) as R
50 } catch {
51 throw new XrpcError(
52 res.status,
53 'ParseError',
54 'Failed to parse response as JSON',
55 )
56 }
57 }
58
59 async procedure<I, O>(nsid: string, input?: I): Promise<O> {
60 const url = new URL(`/xrpc/${nsid}`, this.#baseUrl)
61
62 const res = await this.#fetchFn(url.toString(), {
63 method: 'POST',
64 headers: { 'Content-Type': 'application/json' },
65 body: input !== undefined ? JSON.stringify(input) : undefined,
66 })
67
68 if (!res.ok) {
69 throw await this.#parseError(res)
70 }
71
72 if (res.status === 204) {
73 return undefined as O
74 }
75
76 const text = await res.text()
77 if (!text) {
78 return undefined as O
79 }
80
81 try {
82 return JSON.parse(text) as O
83 } catch {
84 throw new XrpcError(
85 res.status,
86 'ParseError',
87 'Failed to parse response as JSON',
88 )
89 }
90 }
91
92 async #parseError(res: Response): Promise<XrpcError> {
93 try {
94 const body = (await res.json()) as Record<string, unknown>
95 const errorName =
96 typeof body.error === 'string' ? body.error : 'UnknownError'
97 const message =
98 typeof body.message === 'string'
99 ? body.message
100 : `XRPC request failed with status ${res.status}`
101 return new XrpcError(res.status, errorName, message)
102 } catch {
103 return new XrpcError(
104 res.status,
105 'UnknownError',
106 `XRPC request failed with status ${res.status}`,
107 )
108 }
109 }
110}