Thread viewer for Bluesky
1/**
2 * Thrown when status code of an API response is not "success".
3 */
4
5class APIError extends Error {
6
7 /** @param {number} code, @param {json} json */
8 constructor(code, json) {
9 super("APIError status " + code + "\n\n" + JSON.stringify(json));
10 this.code = code;
11 this.json = json;
12 }
13}
14
15
16/**
17 * Thrown when passed arguments/options are invalid or missing.
18 */
19
20class RequestError extends Error {}
21
22
23/**
24 * Thrown when authentication is needed, but access token is invalid or missing.
25 */
26
27class AuthError extends Error {}
28
29
30/**
31 * Thrown when DID or DID document is invalid.
32 */
33
34class DIDError extends Error {}
35
36
37/**
38 * Base API client for connecting to an ATProto XRPC API.
39 */
40
41class Minisky {
42
43 /** @param {string} did, @returns {Promise<string>} */
44
45 static async pdsEndpointForDid(did) {
46 let url;
47
48 if (did.startsWith('did:plc:')) {
49 url = new URL(`https://plc.directory/${did}`);
50 } else if (did.startsWith('did:web:')) {
51 let host = did.replace(/^did:web:/, '');
52 url = new URL(`https://${host}/.well-known/did.json`);
53 } else {
54 throw new DIDError("Unknown DID type: " + did);
55 }
56
57 let response = await fetch(url);
58 let text = await response.text();
59 let json = text.trim().length > 0 ? JSON.parse(text) : undefined;
60
61 if (response.status == 200) {
62 let service = (json.service || []).find(s => s.id == '#atproto_pds');
63 if (service) {
64 return service.serviceEndpoint.replace('https://', '');
65 } else {
66 throw new DIDError("Missing #atproto_pds service definition");
67 }
68 } else {
69 throw new APIError(response.status, json);
70 }
71 }
72
73 /**
74 * @typedef {object} MiniskyOptions
75 * @prop {boolean} [sendAuthHeaders]
76 * @prop {boolean} [autoManageTokens]
77 *
78 * @typedef {object} MiniskyConfig
79 * @prop {json | null | undefined} user
80 * @prop {() => void} save
81 *
82 * @param {string | undefined} host
83 * @param {MiniskyConfig | null | undefined} [config]
84 * @param {MiniskyOptions} [options]
85 */
86
87 constructor(host, config, options) {
88 this.host = host;
89 this.config = config;
90 this.user = /** @type {json} */ (config?.user);
91
92 this.sendAuthHeaders = !!this.user;
93 this.autoManageTokens = !!this.user;
94
95 if (options) {
96 Object.assign(this, options);
97 }
98 }
99
100 /** @returns {string} */
101
102 get baseURL() {
103 if (this.host) {
104 let host = (this.host.includes('://')) ? this.host : `https://${this.host}`;
105 return host + '/xrpc';
106 } else {
107 throw new RequestError('Hostname not set');
108 }
109 }
110
111 /** @returns {boolean} */
112
113 get isLoggedIn() {
114 return !!(this.user && this.user.accessToken && this.user.refreshToken && this.user.did && this.user.pdsEndpoint);
115 }
116
117 /**
118 * @typedef {object} MiniskyRequestOptions
119 * @prop {string | boolean} [auth]
120 * @prop {Record<string, string>} [headers]
121 *
122 * @param {string} method, @param {json | null} [params], @param {MiniskyRequestOptions} [options]
123 * @returns {Promise<json>}
124 */
125
126 async getRequest(method, params, options) {
127 let url = new URL(`${this.baseURL}/${method}`);
128 let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders;
129
130 if (this.autoManageTokens && auth === true) {
131 await this.checkAccess();
132 }
133
134 if (params) {
135 for (let p in params) {
136 if (params[p] instanceof Array) {
137 params[p].forEach(x => url.searchParams.append(p, x));
138 } else {
139 url.searchParams.append(p, params[p]);
140 }
141 }
142 }
143
144 let headers = this.authHeaders(auth);
145
146 if (options && options.headers) {
147 Object.assign(headers, options.headers);
148 }
149
150 let response = await fetch(url, { headers: headers });
151 return await this.parseResponse(response);
152 }
153
154 /**
155 * @param {string} method, @param {json | null} [data], @param {MiniskyRequestOptions} [options]
156 * @returns Promise<json>
157 */
158
159 async postRequest(method, data, options) {
160 let url = `${this.baseURL}/${method}`;
161 let auth = options && ('auth' in options) ? options.auth : this.sendAuthHeaders;
162
163 if (this.autoManageTokens && auth === true) {
164 await this.checkAccess();
165 }
166
167 let request = { method: 'POST', headers: this.authHeaders(auth) };
168
169 if (data) {
170 request.body = JSON.stringify(data);
171 request.headers['Content-Type'] = 'application/json';
172 }
173
174 if (options && options.headers) {
175 Object.assign(request.headers, options.headers);
176 }
177
178 let response = await fetch(url, request);
179 return await this.parseResponse(response);
180 }
181
182 /**
183 * @typedef {(obj: json[]) => { cancel: true } | void} FetchAllOnPageLoad
184 *
185 * @typedef {MiniskyOptions & {
186 * field: string,
187 * params?: json,
188 * breakWhen?: (obj: json) => boolean,
189 * keepLastPage?: boolean | undefined,
190 * onPageLoad?: FetchAllOnPageLoad | undefined
191 * }} FetchAllOptions
192 *
193 * @param {string} method
194 * @param {FetchAllOptions} [options]
195 * @returns {Promise<json[]>}
196 */
197
198 async fetchAll(method, options) {
199 if (!options || !options.field) {
200 throw new RequestError("'field' option is required");
201 }
202
203 let data = [];
204 let reqParams = options.params ?? {};
205 let reqOptions = this.sliceOptions(options, ['auth', 'headers']);
206
207 for (;;) {
208 let response = await this.getRequest(method, reqParams, reqOptions);
209
210 let items = response[options.field];
211 let cursor = response.cursor;
212
213 if (options.breakWhen) {
214 let test = options.breakWhen;
215
216 if (items.some(x => test(x))) {
217 if (!options.keepLastPage) {
218 items = items.filter(x => !test(x));
219 }
220
221 cursor = null;
222 }
223 }
224
225 data = data.concat(items);
226 reqParams.cursor = cursor;
227
228 if (options.onPageLoad) {
229 let result = options.onPageLoad(items);
230
231 if (result?.cancel) {
232 break;
233 }
234 }
235
236 if (!cursor) {
237 break;
238 }
239 }
240
241 return data;
242 }
243
244 /** @param {string | boolean} auth, @returns {Record<string, string>} */
245
246 authHeaders(auth) {
247 if (typeof auth == 'string') {
248 return { 'Authorization': `Bearer ${auth}` };
249 } else if (auth) {
250 if (this.user?.accessToken) {
251 return { 'Authorization': `Bearer ${this.user.accessToken}` };
252 } else {
253 throw new AuthError("Can't send auth headers, access token is missing");
254 }
255 } else {
256 return {};
257 }
258 }
259
260 /** @param {json} options, @param {string[]} list, @returns {json} */
261
262 sliceOptions(options, list) {
263 let newOptions = {};
264
265 for (let i of list) {
266 if (i in options) {
267 newOptions[i] = options[i];
268 }
269 }
270
271 return newOptions;
272 }
273
274 /** @param {string} token, @returns {number} */
275
276 tokenExpirationTimestamp(token) {
277 let parts = token.split('.');
278 if (parts.length != 3) {
279 throw new AuthError("Invalid access token format");
280 }
281
282 let payload = JSON.parse(atob(parts[1]));
283 let exp = payload.exp;
284
285 if (!(exp && typeof exp == 'number' && exp > 0)) {
286 throw new AuthError("Invalid token expiry data");
287 }
288
289 return exp * 1000;
290 }
291
292 /** @param {Response} response, @param {json} json, @returns {boolean} */
293
294 isInvalidToken(response, json) {
295 return (response.status == 400) && !!json && ['InvalidToken', 'ExpiredToken'].includes(json.error);
296 }
297
298 /** @param {Response} response, @returns {Promise<json>} */
299
300 async parseResponse(response) {
301 let text = await response.text();
302 let json = text.trim().length > 0 ? JSON.parse(text) : undefined;
303
304 if (response.status >= 200 && response.status < 300) {
305 return json;
306 } else {
307 throw new APIError(response.status, json);
308 }
309 }
310
311 /** @returns {Promise<void>} */
312
313 async checkAccess() {
314 if (!this.isLoggedIn) {
315 throw new AuthError("Not logged in");
316 }
317
318 let expirationTimestamp = this.tokenExpirationTimestamp(this.user.accessToken);
319
320 if (expirationTimestamp < new Date().getTime() + 60 * 1000) {
321 await this.performTokenRefresh();
322 }
323 }
324
325 /** @param {string} handle, @param {string} password, @returns {Promise<json>} */
326
327 async logIn(handle, password) {
328 if (!this.config || !this.config.user) {
329 throw new AuthError("Missing user configuration object");
330 }
331
332 let params = { identifier: handle, password: password };
333 let json = await this.postRequest('com.atproto.server.createSession', params, { auth: false });
334
335 this.saveTokens(json);
336 return json;
337 }
338
339 /** @returns {Promise<json>} */
340
341 async performTokenRefresh() {
342 if (!this.isLoggedIn) {
343 throw new AuthError("Not logged in");
344 }
345
346 console.log('Refreshing access token…');
347 let json = await this.postRequest('com.atproto.server.refreshSession', null, { auth: this.user.refreshToken });
348 this.saveTokens(json);
349 return json;
350 }
351
352 /** @param {json} json */
353
354 saveTokens(json) {
355 if (!this.config || !this.config.user) {
356 throw new AuthError("Missing user configuration object");
357 }
358
359 this.user.accessToken = json['accessJwt'];
360 this.user.refreshToken = json['refreshJwt'];
361 this.user.did = json['did'];
362
363 if (json.didDoc?.service) {
364 let service = json.didDoc.service.find(s => s.id == '#atproto_pds');
365 this.host = service.serviceEndpoint.replace('https://', '');
366 }
367
368 this.user.pdsEndpoint = this.host;
369 this.config.save();
370 }
371
372 resetTokens() {
373 if (!this.config || !this.config.user) {
374 throw new AuthError("Missing user configuration object");
375 }
376
377 delete this.user.accessToken;
378 delete this.user.refreshToken;
379 delete this.user.did;
380 delete this.user.pdsEndpoint;
381 this.config.save();
382 }
383}