Thread viewer for Bluesky
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}