Thread viewer for Bluesky
at master 9.7 kB view raw
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}