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

Configure Feed

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

at lex 472 lines 12 kB view raw
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}