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 * onPageLoad?: FetchAllOnPageLoad | undefined
190 * }} FetchAllOptions
191 *
192 * @param {string} method
193 * @param {FetchAllOptions} [options]
194 * @returns {Promise<json[]>}
195 */
196
197 async fetchAll(method, options) {
198 if (!options || !options.field) {
199 throw new RequestError("'field' option is required");
200 }
201
202 let data = [];
203 let reqParams = options.params ?? {};
204 let reqOptions = this.sliceOptions(options, ['auth', 'headers']);
205
206 for (;;) {
207 let response = await this.getRequest(method, reqParams, reqOptions);
208
209 let items = response[options.field];
210 let cursor = response.cursor;
211
212 if (options.breakWhen) {
213 let test = options.breakWhen;
214
215 if (items.some(x => test(x))) {
216 items = items.filter(x => !test(x));
217 cursor = null;
218 }
219 }
220
221 data = data.concat(items);
222 reqParams.cursor = cursor;
223
224 if (options.onPageLoad) {
225 let result = options.onPageLoad(items);
226
227 if (result?.cancel) {
228 break;
229 }
230 }
231
232 if (!cursor) {
233 break;
234 }
235 }
236
237 return data;
238 }
239
240 /** @param {string | boolean} auth, @returns {Record<string, string>} */
241
242 authHeaders(auth) {
243 if (typeof auth == 'string') {
244 return { 'Authorization': `Bearer ${auth}` };
245 } else if (auth) {
246 if (this.user?.accessToken) {
247 return { 'Authorization': `Bearer ${this.user.accessToken}` };
248 } else {
249 throw new AuthError("Can't send auth headers, access token is missing");
250 }
251 } else {
252 return {};
253 }
254 }
255
256 /** @param {json} options, @param {string[]} list, @returns {json} */
257
258 sliceOptions(options, list) {
259 let newOptions = {};
260
261 for (let i of list) {
262 if (i in options) {
263 newOptions[i] = options[i];
264 }
265 }
266
267 return newOptions;
268 }
269
270 /** @param {string} token, @returns {number} */
271
272 tokenExpirationTimestamp(token) {
273 let parts = token.split('.');
274 if (parts.length != 3) {
275 throw new AuthError("Invalid access token format");
276 }
277
278 let payload = JSON.parse(atob(parts[1]));
279 let exp = payload.exp;
280
281 if (!(exp && typeof exp == 'number' && exp > 0)) {
282 throw new AuthError("Invalid token expiry data");
283 }
284
285 return exp * 1000;
286 }
287
288 /** @param {Response} response, @param {json} json, @returns {boolean} */
289
290 isInvalidToken(response, json) {
291 return (response.status == 400) && !!json && ['InvalidToken', 'ExpiredToken'].includes(json.error);
292 }
293
294 /** @param {Response} response, @returns {Promise<json>} */
295
296 async parseResponse(response) {
297 let text = await response.text();
298 let json = text.trim().length > 0 ? JSON.parse(text) : undefined;
299
300 if (response.status == 200) {
301 return json;
302 } else {
303 throw new APIError(response.status, json);
304 }
305 }
306
307 /** @returns {Promise<void>} */
308
309 async checkAccess() {
310 if (!this.isLoggedIn) {
311 throw new AuthError("Not logged in");
312 }
313
314 let expirationTimestamp = this.tokenExpirationTimestamp(this.user.accessToken);
315
316 if (expirationTimestamp < new Date().getTime() + 60 * 1000) {
317 await this.performTokenRefresh();
318 }
319 }
320
321 /** @param {string} handle, @param {string} password, @returns {Promise<json>} */
322
323 async logIn(handle, password) {
324 if (!this.config || !this.config.user) {
325 throw new AuthError("Missing user configuration object");
326 }
327
328 let params = { identifier: handle, password: password };
329 let json = await this.postRequest('com.atproto.server.createSession', params, { auth: false });
330
331 this.saveTokens(json);
332 return json;
333 }
334
335 /** @returns {Promise<json>} */
336
337 async performTokenRefresh() {
338 if (!this.isLoggedIn) {
339 throw new AuthError("Not logged in");
340 }
341
342 console.log('Refreshing access token…');
343 let json = await this.postRequest('com.atproto.server.refreshSession', null, { auth: this.user.refreshToken });
344 this.saveTokens(json);
345 return json;
346 }
347
348 /** @param {json} json */
349
350 saveTokens(json) {
351 if (!this.config || !this.config.user) {
352 throw new AuthError("Missing user configuration object");
353 }
354
355 this.user.accessToken = json['accessJwt'];
356 this.user.refreshToken = json['refreshJwt'];
357 this.user.did = json['did'];
358
359 if (json.didDoc?.service) {
360 let service = json.didDoc.service.find(s => s.id == '#atproto_pds');
361 this.host = service.serviceEndpoint.replace('https://', '');
362 }
363
364 this.user.pdsEndpoint = this.host;
365 this.config.save();
366 }
367
368 resetTokens() {
369 if (!this.config || !this.config.user) {
370 throw new AuthError("Missing user configuration object");
371 }
372
373 delete this.user.accessToken;
374 delete this.user.refreshToken;
375 delete this.user.did;
376 delete this.user.pdsEndpoint;
377 this.config.save();
378 }
379}