Suite of AT Protocol TypeScript libraries built on web standards
1import { Procedure, type Query } from "@atp/lex";
2import {
3 type Agent,
4 type AgentOptions,
5 buildAgent,
6 type FetchHandler,
7} from "./agent.ts";
8import {
9 type Gettable,
10 httpResponseCodeToEnum,
11 ResponseType,
12 type XrpcCallOptions,
13 XRPCError,
14 XRPCInvalidResponseError,
15 XRPCResponse,
16} from "./types.ts";
17import {
18 combineHeaders,
19 encodeMethodCallBody,
20 httpResponseBodyParse,
21 isErrorResponseBody,
22} from "./util.ts";
23import type { DidString } from "../lex/core/string-format.ts";
24
25type XrpcMethod = Query | Procedure;
26
27export class XrpcClient {
28 readonly agent: Agent;
29 readonly fetchHandler: FetchHandler;
30 readonly headers: Map<string, Gettable<null | string>> = new Map<
31 string,
32 Gettable<null | string>
33 >();
34
35 constructor(
36 agentOpts: Agent | AgentOptions,
37 ) {
38 this.agent = buildAgent(agentOpts);
39 this.fetchHandler = this.agent.fetchHandler;
40 }
41
42 get did(): DidString | undefined {
43 return this.agent.did;
44 }
45
46 setHeader(key: string, value: Gettable<null | string>): void {
47 this.headers.set(key.toLowerCase(), value);
48 }
49
50 unsetHeader(key: string): void {
51 this.headers.delete(key.toLowerCase());
52 }
53
54 clearHeaders(): void {
55 this.headers.clear();
56 }
57
58 async call<const M extends XrpcMethod>(
59 method: M,
60 options: XrpcCallOptions<M> = {} as XrpcCallOptions<M>,
61 ): Promise<XRPCResponse> {
62 const params = this.getValidatedParams(method, options);
63 const reqUrl = this.constructMethodCallUrl(method, params);
64 const reqHeaders = this.constructMethodCallHeaders(method, options);
65 const reqBody = this.constructMethodCallBody(method, reqHeaders, options);
66
67 const init: RequestInit & { duplex: "half" } = {
68 method: method instanceof Procedure ? "post" : "get",
69 headers: combineHeaders(reqHeaders, this.headers),
70 body: reqBody,
71 duplex: "half",
72 redirect: "follow",
73 signal: options.signal,
74 };
75
76 try {
77 const response = await this.fetchHandler(reqUrl as `/${string}`, init);
78
79 const resStatus = response.status;
80 const resHeaders = Object.fromEntries(response.headers.entries());
81 const resBodyBytes = await response.arrayBuffer();
82 let resBody = this.parseResponseBody(
83 response.headers.get("content-type"),
84 resBodyBytes,
85 );
86
87 const resCode = httpResponseCodeToEnum(resStatus);
88 if (resCode !== ResponseType.Success) {
89 const { error = undefined, message = undefined } =
90 resBody && isErrorResponseBody(resBody) ? resBody : {};
91 throw new XRPCError(resCode, error, message, resHeaders);
92 }
93
94 this.assertValidResponseEncoding(method, response, resBody);
95
96 if (options.validateResponse !== false && method.output.schema) {
97 const result = method.output.schema.safeParse(resBody);
98 if (!result.success) {
99 throw new XRPCInvalidResponseError(
100 method.nsid,
101 result.error,
102 resBody,
103 );
104 }
105 resBody = result.value;
106 }
107
108 return new XRPCResponse(resBody, resHeaders);
109 } catch (err) {
110 throw XRPCError.from(err);
111 }
112 }
113
114 private getValidatedParams<M extends XrpcMethod>(
115 method: M,
116 options: XrpcCallOptions<M>,
117 ): Record<string, unknown> | undefined {
118 if (options.validateRequest !== true) {
119 return options.params as Record<string, unknown> | undefined;
120 }
121
122 const result = method.parameters.safeParse(options.params);
123 if (!result.success) {
124 throw new XRPCError(
125 ResponseType.InvalidRequest,
126 undefined,
127 result.error.message,
128 undefined,
129 { cause: result.error },
130 );
131 }
132
133 return result.value as Record<string, unknown> | undefined;
134 }
135
136 private constructMethodCallUrl(
137 method: XrpcMethod,
138 params?: Record<string, unknown>,
139 ): string {
140 const pathname = `/xrpc/${encodeURIComponent(method.nsid)}`;
141 const searchParams = method.parameters.toURLSearchParams(
142 (params ?? {}) as Record<string, unknown>,
143 );
144 const query = searchParams.toString();
145 return query.length > 0 ? `${pathname}?${query}` : pathname;
146 }
147
148 private constructMethodCallHeaders<M extends XrpcMethod>(
149 method: M,
150 options: XrpcCallOptions<M>,
151 ): Headers {
152 const headers = new Headers();
153
154 if (options.headers != null) {
155 for (const [name, value] of Object.entries(options.headers)) {
156 if (value !== undefined) {
157 headers.set(name, value);
158 }
159 }
160 }
161
162 if (method.output.encoding !== undefined) {
163 headers.set("accept", method.output.encoding);
164 }
165
166 return headers;
167 }
168
169 private constructMethodCallBody<M extends XrpcMethod>(
170 method: M,
171 headers: Headers,
172 options: XrpcCallOptions<M>,
173 ): BodyInit | undefined {
174 if (!(method instanceof Procedure)) {
175 return undefined;
176 }
177
178 let body = options.body as unknown;
179
180 if (options.validateRequest === true && method.input.schema) {
181 const result = method.input.schema.safeParse(body);
182 if (!result.success) {
183 throw new XRPCError(
184 ResponseType.InvalidRequest,
185 undefined,
186 result.error.message,
187 undefined,
188 { cause: result.error },
189 );
190 }
191 body = result.value;
192 }
193
194 const headerEncoding = headers.get("content-type") ?? undefined;
195 if (
196 options.encoding !== undefined &&
197 headerEncoding !== undefined &&
198 !matchesEncoding(options.encoding, headerEncoding)
199 ) {
200 throw new XRPCError(
201 ResponseType.InvalidRequest,
202 undefined,
203 `Conflicting content-type values: ${options.encoding} and ${headerEncoding}`,
204 );
205 }
206
207 const resolved = resolveProcedurePayload(
208 method.input.encoding,
209 body,
210 options.encoding ?? headerEncoding,
211 );
212
213 if (resolved === undefined) {
214 headers.delete("content-type");
215 return undefined;
216 }
217
218 headers.set("content-type", resolved.encoding);
219 return encodeMethodCallBody(headers, body);
220 }
221
222 private parseResponseBody(
223 mimeType: string | null,
224 data: ArrayBuffer,
225 ): unknown {
226 if (data.byteLength === 0 && mimeType == null) {
227 return undefined;
228 }
229
230 return httpResponseBodyParse(mimeType, data);
231 }
232
233 private assertValidResponseEncoding(
234 method: XrpcMethod,
235 response: Response,
236 body: unknown,
237 ): void {
238 const expected = method.output.encoding;
239 const contentType = response.headers.get("content-type");
240
241 if (expected === undefined) {
242 if (body !== undefined) {
243 throw new XRPCError(
244 ResponseType.InvalidResponse,
245 undefined,
246 `Expected empty response body for ${method.nsid}`,
247 );
248 }
249 return;
250 }
251
252 if (contentType == null) {
253 throw new XRPCError(
254 ResponseType.InvalidResponse,
255 undefined,
256 `Missing content-type in response for ${method.nsid}`,
257 );
258 }
259
260 if (!matchesEncoding(expected, contentType)) {
261 throw new XRPCError(
262 ResponseType.InvalidResponse,
263 undefined,
264 `Unexpected response content-type: ${contentType}`,
265 );
266 }
267 }
268}
269
270function resolveProcedurePayload(
271 schemaEncoding: string | undefined,
272 body: unknown,
273 encodingHint: string | undefined,
274): undefined | { encoding: string } {
275 if (schemaEncoding === undefined) {
276 if (body !== undefined) {
277 throw new XRPCError(
278 ResponseType.InvalidRequest,
279 undefined,
280 "Cannot send a request body for a method without input payload",
281 );
282 }
283 if (encodingHint !== undefined) {
284 throw new XRPCError(
285 ResponseType.InvalidRequest,
286 undefined,
287 `Unexpected encoding hint (${encodingHint})`,
288 );
289 }
290 return undefined;
291 }
292
293 if (body === undefined) {
294 throw new XRPCError(
295 ResponseType.InvalidRequest,
296 undefined,
297 "A request body is expected but none was provided",
298 );
299 }
300
301 return {
302 encoding: resolveEncoding(schemaEncoding, body, encodingHint),
303 };
304}
305
306function resolveEncoding(
307 schemaEncoding: string,
308 body: unknown,
309 encodingHint: string | undefined,
310): string {
311 if (encodingHint != null && encodingHint.length > 0) {
312 if (!matchesEncoding(schemaEncoding, encodingHint)) {
313 throw new XRPCError(
314 ResponseType.InvalidRequest,
315 undefined,
316 `Cannot send content-type "${encodingHint}" for "${schemaEncoding}" encoding`,
317 );
318 }
319 return encodingHint;
320 }
321
322 const inferredEncoding = inferEncoding(body);
323 if (
324 inferredEncoding !== undefined &&
325 matchesEncoding(schemaEncoding, inferredEncoding)
326 ) {
327 return inferredEncoding;
328 }
329
330 if (schemaEncoding === "*/*") {
331 return "application/octet-stream";
332 }
333
334 if (schemaEncoding.startsWith("text/")) {
335 if (!schemaEncoding.includes("*")) {
336 return `${schemaEncoding};charset=UTF-8`;
337 }
338 return "text/plain;charset=UTF-8";
339 }
340
341 if (!schemaEncoding.includes("*")) {
342 return schemaEncoding;
343 }
344
345 if (
346 isBlobLike(body) &&
347 body.type.length > 0 &&
348 matchesEncoding(schemaEncoding, body.type)
349 ) {
350 return body.type;
351 }
352
353 if (schemaEncoding.startsWith("application/")) {
354 return "application/octet-stream";
355 }
356
357 throw new XRPCError(
358 ResponseType.InvalidRequest,
359 undefined,
360 `Unable to determine payload encoding for ${schemaEncoding}`,
361 );
362}
363
364function inferEncoding(body: unknown): string | undefined {
365 if (
366 body instanceof ArrayBuffer ||
367 ArrayBuffer.isView(body) ||
368 isReadableStreamLike(body)
369 ) {
370 return "application/octet-stream";
371 }
372
373 if (isFormDataLike(body)) {
374 return "multipart/form-data";
375 }
376
377 if (isURLSearchParamsLike(body)) {
378 return "application/x-www-form-urlencoded;charset=UTF-8";
379 }
380
381 if (isBlobLike(body)) {
382 return body.type || "application/octet-stream";
383 }
384
385 if (typeof body === "string") {
386 return "text/plain;charset=UTF-8";
387 }
388
389 if (isIterable(body)) {
390 return "application/octet-stream";
391 }
392
393 if (
394 typeof body === "boolean" ||
395 typeof body === "number" ||
396 typeof body === "object"
397 ) {
398 return "application/json";
399 }
400
401 return undefined;
402}
403
404function matchesEncoding(pattern: string, value: string): boolean {
405 const normalizedPattern = normalizeEncoding(pattern);
406 const normalizedValue = normalizeEncoding(value);
407
408 if (normalizedPattern === "*/*") {
409 return true;
410 }
411
412 const [patternType, patternSubtype] = normalizedPattern.split("/");
413 const [valueType, valueSubtype] = normalizedValue.split("/");
414
415 if (
416 patternType == null ||
417 patternSubtype == null ||
418 valueType == null ||
419 valueSubtype == null
420 ) {
421 return false;
422 }
423
424 if (patternType !== "*" && patternType !== valueType) {
425 return false;
426 }
427
428 if (patternSubtype !== "*" && patternSubtype !== valueSubtype) {
429 return false;
430 }
431
432 return true;
433}
434
435function normalizeEncoding(encoding: string): string {
436 return encoding.split(";", 1)[0].trim().toLowerCase();
437}
438
439function isBlobLike(value: unknown): value is Blob {
440 if (value == null) return false;
441 if (typeof value !== "object") return false;
442 if (typeof Blob === "function" && value instanceof Blob) return true;
443
444 const tag = (value as Record<string | symbol, unknown>)[Symbol.toStringTag];
445 if (tag === "Blob" || tag === "File") {
446 return "stream" in value && typeof value.stream === "function";
447 }
448
449 return false;
450}
451
452function isReadableStreamLike(value: unknown): value is ReadableStream {
453 return typeof ReadableStream === "function" &&
454 value instanceof ReadableStream;
455}
456
457function isFormDataLike(value: unknown): value is FormData {
458 return typeof FormData === "function" && value instanceof FormData;
459}
460
461function isURLSearchParamsLike(value: unknown): value is URLSearchParams {
462 return typeof URLSearchParams === "function" &&
463 value instanceof URLSearchParams;
464}
465
466function isIterable(
467 value: unknown,
468): value is Iterable<unknown> | AsyncIterable<unknown> {
469 return value != null &&
470 typeof value === "object" &&
471 (Symbol.iterator in value || Symbol.asyncIterator in value);
472}