Suite of AT Protocol TypeScript libraries built on web standards
1import { l, type Procedure, Query, type Validator } from "@atp/lex";
2import type { LexiconDoc } from "@atp/lexicon";
3import {
4 type Agent,
5 type AgentOptions,
6 Client as ModernClient,
7 ResponseType,
8 type XrpcCallOptions,
9 XRPCError,
10 XRPCInvalidResponseError,
11 type XRPCResponse,
12} from "@atp/xrpc";
13
14type Method = Query | Procedure;
15
16type LegacyCallOptions = {
17 encoding?: string;
18 signal?: AbortSignal;
19 headers?: Record<string, string | undefined>;
20 validateRequest?: boolean;
21 validateResponse?: boolean;
22};
23
24type LexRecord = Record<string, unknown>;
25
26export { ResponseType, XRPCError, XRPCInvalidResponseError };
27
28export class Client {
29 readonly #client: ModernClient;
30 readonly #methods: Map<string, Method>;
31
32 constructor(agentOpts: Agent | AgentOptions, lexicons: LexiconDoc[] = []) {
33 this.#client = new ModernClient(agentOpts);
34 this.#methods = buildMethodMap(lexicons);
35 }
36
37 get did() {
38 return this.#client.did;
39 }
40
41 setHeader(
42 key: string,
43 value: string | null | (() => string | null),
44 ): void {
45 this.#client.setHeader(key, value);
46 }
47
48 unsetHeader(key: string): void {
49 this.#client.unsetHeader(key);
50 }
51
52 clearHeaders(): void {
53 this.#client.clearHeaders();
54 }
55
56 async call(
57 nsid: string,
58 params?: Record<string, unknown>,
59 dataOrOptions?: unknown,
60 options?: LegacyCallOptions,
61 ): Promise<XRPCResponse> {
62 const method = this.#methods.get(nsid) ?? l.query(
63 nsid as `${string}.${string}.${string}`,
64 l.params(),
65 l.payload(),
66 );
67 if (method instanceof Query) {
68 const callOptions = options ?? toLegacyCallOptions(dataOrOptions);
69 return await this.#client.call(
70 method,
71 {
72 params,
73 encoding: callOptions?.encoding,
74 signal: callOptions?.signal,
75 headers: callOptions?.headers,
76 validateRequest: callOptions?.validateRequest,
77 validateResponse: callOptions?.validateResponse,
78 } as XrpcCallOptions<typeof method>,
79 );
80 }
81
82 return await this.#client.call(
83 method,
84 {
85 params,
86 body: dataOrOptions,
87 encoding: options?.encoding,
88 signal: options?.signal,
89 headers: options?.headers,
90 validateRequest: options?.validateRequest,
91 validateResponse: options?.validateResponse,
92 } as XrpcCallOptions<typeof method>,
93 );
94 }
95}
96
97export { Client as XrpcClient };
98
99function buildMethodMap(lexicons: LexiconDoc[]): Map<string, Method> {
100 const methods = new Map<string, Method>();
101
102 for (const lexicon of lexicons) {
103 const defs = asRecord(lexicon.defs);
104 const main = asRecord(defs?.main);
105 if (main == null) {
106 continue;
107 }
108
109 const params = compileParams(main.parameters);
110 const errors = compileErrors(main.errors);
111 if (main.type === "query") {
112 methods.set(
113 lexicon.id,
114 l.query(
115 lexicon.id as `${string}.${string}.${string}`,
116 params,
117 compilePayload(main.output),
118 errors,
119 ),
120 );
121 continue;
122 }
123
124 if (main.type === "procedure") {
125 methods.set(
126 lexicon.id,
127 l.procedure(
128 lexicon.id as `${string}.${string}.${string}`,
129 params,
130 compilePayload(main.input),
131 compilePayload(main.output),
132 errors,
133 ),
134 );
135 }
136 }
137
138 return methods;
139}
140
141function compileErrors(definition: unknown): readonly string[] | undefined {
142 if (!Array.isArray(definition)) {
143 return undefined;
144 }
145 const errors: string[] = [];
146 for (const item of definition) {
147 const error = asRecord(item);
148 if (error == null || typeof error.name !== "string") {
149 continue;
150 }
151 errors.push(error.name);
152 }
153 return errors.length > 0 ? errors : undefined;
154}
155
156function compilePayload(definition: unknown) {
157 const payload = asRecord(definition);
158 const encoding = typeof payload?.encoding === "string"
159 ? payload.encoding
160 : undefined;
161 const schema = compileSchema(payload?.schema);
162 if (schema === undefined) {
163 return l.payload(encoding);
164 }
165 return l.payload(encoding, schema);
166}
167
168function compileParams(definition: unknown) {
169 const params = asRecord(definition);
170 const properties = asRecord(params?.properties);
171 if (properties == null) {
172 return l.params();
173 }
174
175 const required = new Set(toStringArray(params?.required));
176 const validators: Record<string, Validator> = {};
177 for (const [key, value] of Object.entries(properties)) {
178 const schema = compileSchema(value);
179 if (schema === undefined) {
180 continue;
181 }
182 if (required.has(key) || hasDefault(value)) {
183 validators[key] = schema;
184 } else {
185 validators[key] = l.optional(schema);
186 }
187 }
188 return l.params(validators);
189}
190
191function compileSchema(definition: unknown): Validator | undefined {
192 const schema = asRecord(definition);
193 if (schema == null) {
194 return undefined;
195 }
196
197 switch (schema.type) {
198 case "boolean":
199 return l.boolean({
200 default: getBoolean(schema.default),
201 const: getBoolean(schema.const),
202 });
203 case "integer":
204 return l.integer({
205 default: getNumber(schema.default),
206 minimum: getNumber(schema.minimum),
207 maximum: getNumber(schema.maximum),
208 const: getNumber(schema.const),
209 });
210 case "string":
211 return l.string({
212 default: getString(schema.default),
213 minLength: getNumber(schema.minLength),
214 maxLength: getNumber(schema.maxLength),
215 });
216 case "array": {
217 const items = compileSchema(schema.items) ?? l.unknown();
218 return l.array(items, {
219 minLength: getNumber(schema.minLength),
220 maxLength: getNumber(schema.maxLength),
221 });
222 }
223 case "object": {
224 const properties = asRecord(schema.properties) ?? {};
225 const required = new Set(toStringArray(schema.required));
226 const fields: Record<string, Validator> = {};
227 for (const [key, value] of Object.entries(properties)) {
228 const fieldSchema = compileSchema(value);
229 if (fieldSchema === undefined) {
230 continue;
231 }
232 if (required.has(key) || hasDefault(value)) {
233 fields[key] = fieldSchema;
234 } else {
235 fields[key] = l.optional(fieldSchema);
236 }
237 }
238 return l.object(fields);
239 }
240 case "bytes":
241 return l.bytes({
242 minLength: getNumber(schema.minLength),
243 maxLength: getNumber(schema.maxLength),
244 });
245 case "cid-link":
246 return l.cidLink();
247 default:
248 return l.unknown();
249 }
250}
251
252function toLegacyCallOptions(value: unknown): LegacyCallOptions | undefined {
253 const options = asRecord(value);
254 if (options == null) {
255 return undefined;
256 }
257 if (
258 !("encoding" in options) &&
259 !("signal" in options) &&
260 !("headers" in options) &&
261 !("validateRequest" in options) &&
262 !("validateResponse" in options)
263 ) {
264 return undefined;
265 }
266 return options as LegacyCallOptions;
267}
268
269function hasDefault(value: unknown): boolean {
270 const schema = asRecord(value);
271 return schema != null && "default" in schema;
272}
273
274function toStringArray(value: unknown): string[] {
275 if (!Array.isArray(value)) {
276 return [];
277 }
278 return value.filter((item): item is string => typeof item === "string");
279}
280
281function asRecord(value: unknown): LexRecord | undefined {
282 if (value == null || typeof value !== "object" || Array.isArray(value)) {
283 return undefined;
284 }
285 return value as LexRecord;
286}
287
288function getNumber(value: unknown): number | undefined {
289 return typeof value === "number" ? value : undefined;
290}
291
292function getString(value: unknown): string | undefined {
293 return typeof value === "string" ? value : undefined;
294}
295
296function getBoolean(value: unknown): boolean | undefined {
297 return typeof value === "boolean" ? value : undefined;
298}