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 * breakWhen?: (obj: json) => boolean, 188 * onPageLoad?: FetchAllOnPageLoad | undefined 189 * }} FetchAllOptions 190 * 191 * @param {string} method 192 * @param {json | null} params 193 * @param {FetchAllOptions} [options] 194 * @returns {Promise<json[]>} 195 */ 196 197 async fetchAll(method, params, options) { 198 if (!options || !options.field) { 199 throw new RequestError("'field' option is required"); 200 } 201 202 let data = []; 203 let reqParams = 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}