Suite of AT Protocol TypeScript libraries built on web standards
20
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 576 lines 15 kB view raw
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}