Suite of AT Protocol TypeScript libraries built on web standards
1import { l } from "@atp/lex";
2import { assertEquals, assertRejects } from "@std/assert";
3import { Client, xrpc, xrpcSafe } from "../mod.ts";
4import type { XrpcCallCompatibleOptions } from "../types.ts";
5import { XRPCError, XRPCInvalidResponseError } from "../types.ts";
6
7type Expect<T extends true> = T;
8type IsNever<T> = [T] extends [never] ? true : false;
9
10Deno.test("calls query with lex method and params", async () => {
11 const method = l.query(
12 "io.example.query",
13 l.params({ limit: l.optional(l.integer()) }),
14 l.jsonPayload({ value: l.string() }),
15 );
16
17 const client = new Client((url, init) => {
18 assertEquals(url, "/xrpc/io.example.query?limit=7");
19 assertEquals(init.method, "get");
20 return Promise.resolve(Response.json({ value: "ok" }));
21 });
22
23 const result = await client.call(method, {
24 params: { limit: 7 },
25 });
26
27 assertEquals(result.data, { value: "ok" });
28});
29
30Deno.test("calls query with xrpc", async () => {
31 const method = l.query(
32 "io.example.query",
33 l.params({ limit: l.optional(l.integer()) }),
34 l.jsonPayload({ value: l.string() }),
35 );
36
37 const client = new Client((url, init) => {
38 assertEquals(url, "/xrpc/io.example.query?limit=9");
39 assertEquals(init.method, "get");
40 return Promise.resolve(Response.json({ value: "ok" }));
41 });
42
43 const result = await client.xrpc(method, {
44 params: { limit: 9 },
45 });
46
47 assertEquals(result.data, { value: "ok" });
48});
49
50Deno.test("calls top-level xrpc", async () => {
51 const method = l.query(
52 "io.example.query",
53 l.params({ limit: l.optional(l.integer()) }),
54 l.jsonPayload({ value: l.string() }),
55 );
56
57 const result = await xrpc(
58 (url, init) => {
59 assertEquals(url, "/xrpc/io.example.query?limit=6");
60 assertEquals(init.method, "get");
61 return Promise.resolve(Response.json({ value: "ok" }));
62 },
63 method,
64 {
65 params: { limit: 6 },
66 },
67 );
68
69 assertEquals(result.data, { value: "ok" });
70});
71
72Deno.test("narrows xrpcSafe success results on success flag", async () => {
73 const method = l.query(
74 "io.example.query",
75 l.params({ limit: l.optional(l.integer()) }),
76 l.jsonPayload({ value: l.string() }),
77 );
78
79 const client = new Client((url, init) => {
80 assertEquals(url, "/xrpc/io.example.query?limit=8");
81 assertEquals(init.method, "get");
82 return Promise.resolve(Response.json({ value: "ok" }));
83 });
84
85 const result = await client.xrpcSafe(method, {
86 params: { limit: 8 },
87 });
88
89 assertEquals(result.success, true);
90
91 if (result.success) {
92 assertEquals(result.data, { value: "ok" });
93 } else {
94 throw new Error(result.error);
95 }
96});
97
98Deno.test("calls top-level xrpcSafe", async () => {
99 const method = l.query(
100 "io.example.query",
101 l.params({ limit: l.optional(l.integer()) }),
102 l.jsonPayload({ value: l.string() }),
103 );
104
105 const result = await xrpcSafe(
106 () =>
107 Promise.resolve(
108 Response.json(
109 { error: "BadRequest", message: "nope" },
110 { status: 400 },
111 ),
112 ),
113 method,
114 {
115 params: { limit: 2 },
116 },
117 );
118
119 assertEquals(result.success, false);
120
121 if (!result.success) {
122 assertEquals(result.error, "BadRequest");
123 assertEquals(result.message, "nope");
124 } else {
125 throw new Error(JSON.stringify(result.data));
126 }
127});
128
129Deno.test("keeps call as a compatibility alias for xrpc", async () => {
130 const method = l.query(
131 "io.example.query",
132 l.params({ limit: l.optional(l.integer()) }),
133 l.jsonPayload({ value: l.string() }),
134 );
135
136 const client = new Client((url, init) => {
137 assertEquals(url, "/xrpc/io.example.query?limit=4");
138 assertEquals(init.method, "get");
139 return Promise.resolve(Response.json({ value: "ok" }));
140 });
141
142 const result = await client.call(method, {
143 params: { limit: 4 },
144 });
145
146 assertEquals(result.data, { value: "ok" });
147});
148
149Deno.test("serializes params using schema transforms", async () => {
150 const method = l.query(
151 "io.example.query",
152 l.params({
153 since: l.optional(l.string({ format: "datetime" })),
154 }),
155 l.jsonPayload({ value: l.string() }),
156 );
157
158 const client = new Client((url) => {
159 assertEquals(
160 url,
161 "/xrpc/io.example.query?since=2024-01-02T03%3A04%3A05.000Z",
162 );
163 return Promise.resolve(Response.json({ value: "ok" }));
164 });
165
166 const result = await client.call(method, {
167 params: {
168 since: new Date("2024-01-02T03:04:05.000Z"),
169 } as unknown as never,
170 });
171
172 assertEquals(result.data, { value: "ok" });
173});
174
175Deno.test("accepts plain strings for formatted query params", async () => {
176 const method = l.query(
177 "io.example.getRecord",
178 l.params({
179 repo: l.string({ format: "at-identifier" }),
180 collection: l.string({ format: "nsid" }),
181 rkey: l.string({ format: "record-key" }),
182 uri: l.optional(l.string({ format: "uri" })),
183 }),
184 l.payload(),
185 );
186
187 const client = new Client((url, init) => {
188 assertEquals(
189 url,
190 "/xrpc/io.example.getRecord?repo=did%3Aplc%3A6hbqm2oftpotwuw7gvvrui3i&collection=app.bsky.feed.post&rkey=3mjlhmszzo22h&uri=https%3A%2F%2Fexample.com%2Fpost%2F1",
191 );
192 assertEquals(init.method, "get");
193 return Promise.resolve(new Response(null));
194 });
195
196 await client.call(method, {
197 params: {
198 repo: "did:plc:6hbqm2oftpotwuw7gvvrui3i",
199 collection: "app.bsky.feed.post",
200 rkey: "3mjlhmszzo22h",
201 uri: "https://example.com/post/1",
202 },
203 });
204});
205
206Deno.test("only matching string literals satisfy formatted params", () => {
207 const method = l.query(
208 "io.example.getRecord",
209 l.params({
210 repo: l.string({ format: "at-identifier" }),
211 collection: l.string({ format: "nsid" }),
212 rkey: l.string({ format: "record-key" }),
213 }),
214 l.payload(),
215 );
216
217 type Valid = XrpcCallCompatibleOptions<typeof method, {
218 params: {
219 repo: "did:plc:6hbqm2oftpotwuw7gvvrui3i";
220 collection: "app.bsky.feed.post";
221 rkey: "3mjlhmszzo22h";
222 };
223 }>;
224 type InvalidRepo = XrpcCallCompatibleOptions<typeof method, {
225 params: {
226 repo: "not-a-valid-at-identifier";
227 collection: "app.bsky.feed.post";
228 rkey: "3mjlhmszzo22h";
229 };
230 }>;
231 type GenericRepo = XrpcCallCompatibleOptions<typeof method, {
232 params: {
233 repo: string;
234 collection: "app.bsky.feed.post";
235 rkey: "3mjlhmszzo22h";
236 };
237 }>;
238
239 type ValidParams = NonNullable<Valid["params"]>;
240 type InvalidRepoParams = NonNullable<InvalidRepo["params"]>;
241 type GenericRepoParams = NonNullable<GenericRepo["params"]>;
242
243 type _validRepo = Expect<
244 IsNever<ValidParams["repo"]> extends false ? true : false
245 >;
246 type _invalidRepo = Expect<IsNever<InvalidRepoParams["repo"]>>;
247 type _genericRepo = Expect<IsNever<GenericRepoParams["repo"]>>;
248});
249
250Deno.test("calls query with namespace main export", async () => {
251 const main = l.query(
252 "io.example.query",
253 l.params({ limit: l.optional(l.integer()) }),
254 l.jsonPayload({ value: l.string() }),
255 );
256 const namespace = { main } as const;
257
258 const client = new Client((url, init) => {
259 assertEquals(url, "/xrpc/io.example.query?limit=3");
260 assertEquals(init.method, "get");
261 return Promise.resolve(Response.json({ value: "ok" }));
262 });
263
264 const result = await client.call(namespace, {
265 params: { limit: 3 },
266 });
267
268 assertEquals(result.data, { value: "ok" });
269});
270
271Deno.test("calls query with namespace Main export", async () => {
272 const Main = l.query(
273 "io.example.query",
274 l.params({ limit: l.optional(l.integer()) }),
275 l.jsonPayload({ value: l.string() }),
276 );
277 const namespace = { Main } as const;
278
279 const client = new Client((url, init) => {
280 assertEquals(url, "/xrpc/io.example.query?limit=5");
281 assertEquals(init.method, "get");
282 return Promise.resolve(Response.json({ value: "ok" }));
283 });
284
285 const result = await client.call(namespace, {
286 params: { limit: 5 },
287 });
288
289 assertEquals(result.data, { value: "ok" });
290});
291
292Deno.test("validates request and response when enabled", async () => {
293 const method = l.procedure(
294 "io.example.proc",
295 l.params(),
296 l.jsonPayload({ text: l.string() }),
297 l.jsonPayload({ id: l.string() }),
298 );
299
300 const client = new Client(() => Promise.resolve(Response.json({ id: 123 })));
301
302 await assertRejects(
303 async () => {
304 await client.call(method, {
305 body: { text: 1 } as unknown as { text: string },
306 validateRequest: true,
307 });
308 },
309 XRPCError,
310 );
311
312 await assertRejects(
313 async () => {
314 await client.call(method, {
315 body: { text: "hello" },
316 validateResponse: true,
317 });
318 },
319 XRPCInvalidResponseError,
320 );
321});
322
323Deno.test("returns xrpc errors from xrpcSafe", async () => {
324 const method = l.query(
325 "io.example.query",
326 l.params({ limit: l.optional(l.integer()) }),
327 l.jsonPayload({ value: l.string() }),
328 );
329
330 const client = new Client(() =>
331 Promise.resolve(
332 Response.json(
333 { error: "BadRequest", message: "nope" },
334 { status: 400 },
335 ),
336 )
337 );
338
339 const result = await client.xrpcSafe(method, {
340 params: { limit: 1 },
341 });
342
343 assertEquals(result.success, false);
344
345 if (!result.success) {
346 assertEquals(result.success, false);
347 assertEquals(result.error, "BadRequest");
348 assertEquals(result.message, "nope");
349 } else {
350 throw new Error(JSON.stringify(result.data));
351 }
352});
353
354Deno.test("accepts formatted strings in json request bodies", async () => {
355 const method = l.procedure(
356 "io.example.proc",
357 l.params(),
358 l.jsonPayload({
359 repo: l.string({ format: "at-identifier" }),
360 rkey: l.string({ format: "record-key" }),
361 createdAt: l.string({ format: "datetime" }),
362 }),
363 l.payload(),
364 );
365
366 const client = new Client((_url, init) => {
367 assertEquals(init.method, "post");
368 assertEquals(
369 new Headers(init.headers).get("content-type"),
370 "application/json",
371 );
372 return Promise.resolve(new Response(null));
373 });
374
375 await client.call(method, {
376 body: {
377 repo: "did:plc:6hbqm2oftpotwuw7gvvrui3i",
378 rkey: "3mjlhmszzo22h",
379 createdAt: "2024-01-02T03:04:05.000Z",
380 },
381 });
382});
383
384Deno.test("only matching string literals satisfy formatted json bodies", () => {
385 const method = l.procedure(
386 "io.example.proc",
387 l.params(),
388 l.jsonPayload({
389 createdAt: l.string({ format: "datetime" }),
390 uri: l.string({ format: "at-uri" }),
391 cid: l.string({ format: "cid" }),
392 }),
393 l.payload(),
394 );
395
396 type Valid = XrpcCallCompatibleOptions<typeof method, {
397 body: {
398 createdAt: "2024-01-02T03:04:05.000Z";
399 uri:
400 "at://did:plc:6hbqm2oftpotwuw7gvvrui3i/app.bsky.feed.post/3mjlhmszzo22h";
401 cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
402 };
403 }>;
404 type InvalidDatetime = XrpcCallCompatibleOptions<typeof method, {
405 body: {
406 createdAt: "2024-01-02";
407 uri:
408 "at://did:plc:6hbqm2oftpotwuw7gvvrui3i/app.bsky.feed.post/3mjlhmszzo22h";
409 cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
410 };
411 }>;
412 type InvalidUri = XrpcCallCompatibleOptions<typeof method, {
413 body: {
414 createdAt: "2024-01-02T03:04:05.000Z";
415 uri: "https://example.com/post/1";
416 cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku";
417 };
418 }>;
419
420 type ValidBody = NonNullable<Valid["body"]>;
421 type InvalidDatetimeBody = NonNullable<InvalidDatetime["body"]>;
422 type InvalidUriBody = NonNullable<InvalidUri["body"]>;
423
424 type _validBody = Expect<
425 IsNever<ValidBody["createdAt"]> extends false ? true : false
426 >;
427 type _invalidDatetime = Expect<IsNever<InvalidDatetimeBody["createdAt"]>>;
428 type _invalidUri = Expect<IsNever<InvalidUriBody["uri"]>>;
429});
430
431Deno.test("uses method encoding defaults for wildcard payloads", async () => {
432 const method = l.procedure(
433 "io.example.upload",
434 l.params(),
435 l.payload("image/*"),
436 l.jsonPayload({ ok: l.boolean() }),
437 );
438
439 const client = new Client((_url, init) => {
440 const headers = new Headers(init.headers);
441 assertEquals(headers.get("content-type"), "image/png");
442 assertEquals(init.method, "post");
443 return Promise.resolve(Response.json({ ok: true }));
444 });
445
446 const blob = new Blob([new Uint8Array([1, 2, 3])], { type: "image/png" });
447 const result = await client.call(method, {
448 body: blob,
449 });
450
451 assertEquals(result.data, { ok: true });
452});
453
454Deno.test("preserves specific blob types for text wildcard payloads", async () => {
455 const method = l.procedure(
456 "io.example.upload",
457 l.params(),
458 l.payload("text/*"),
459 l.payload(),
460 );
461
462 const client = new Client((_url, init) => {
463 const headers = new Headers(init.headers);
464 assertEquals(headers.get("content-type"), "text/csv");
465 return Promise.resolve(new Response(null));
466 });
467
468 await client.call(method, {
469 body: new Blob(["a,b\n1,2"], { type: "text/csv" }) as unknown as never,
470 });
471});
472
473Deno.test("infers body content types for any wildcard payloads", async () => {
474 const method = l.procedure(
475 "io.example.upload",
476 l.params(),
477 l.payload("*/*"),
478 l.payload(),
479 );
480
481 const seen: string[] = [];
482 const client = new Client((_url, init) => {
483 seen.push(new Headers(init.headers).get("content-type") ?? "");
484 return Promise.resolve(new Response(null));
485 });
486
487 await client.call(method, {
488 body: "hello",
489 });
490
491 await client.call(method, {
492 body: new Blob(["<p>ok</p>"], { type: "text/html" }),
493 });
494
495 assertEquals(seen, [
496 "text/plain;charset=UTF-8",
497 "text/html",
498 ]);
499});