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