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