@atcute/client 3.0.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,65 +1,71 @@
1
1
  import type { At, ComAtprotoServerCreateSession } from './lexicons.js';
2
2
 
3
+ import { Client, ClientResponseError, isXRPCErrorPayload, ok } from './client.js';
3
4
  import { simpleFetchHandler, type FetchHandlerObject } from './fetch-handler.js';
4
- import { XRPC, XRPCError } from './rpc.js';
5
5
 
6
6
  import { getPdsEndpoint, type DidDocument } from './utils/did.js';
7
7
  import { decodeJwt } from './utils/jwt.js';
8
8
 
9
- /** Interface for the decoded access token, for convenience */
9
+ /**
10
+ * represents the decoded access token, for convenience
11
+ * @deprecated
12
+ */
10
13
  export interface AtpAccessJwt {
11
- /** Access token scope, app password returns a different scope. */
14
+ /** access token scope */
12
15
  scope:
13
16
  | 'com.atproto.access'
14
17
  | 'com.atproto.appPass'
15
18
  | 'com.atproto.appPassPrivileged'
16
19
  | 'com.atproto.signupQueued'
17
20
  | 'com.atproto.takendown';
18
- /** Account DID */
21
+ /** account DID */
19
22
  sub: At.Did;
20
- /** Expiration time */
23
+ /** expiration time in Unix seconds */
21
24
  exp: number;
22
- /** Creation/issued time */
25
+ /** token issued time in Unix seconds */
23
26
  iat: number;
24
27
  }
25
28
 
26
- /** Interface for the decoded refresh token, for convenience */
29
+ /**
30
+ * represents the the decoded refresh token, for convenience
31
+ * @deprecated
32
+ */
27
33
  export interface AtpRefreshJwt {
28
- /** Refresh token scope */
34
+ /** refresh token scope */
29
35
  scope: 'com.atproto.refresh';
30
- /** ID of this refresh token */
36
+ /** unique identifier for this session */
31
37
  jti: string;
32
- /** Account DID */
38
+ /** account DID */
33
39
  sub: At.Did;
34
- /** Intended audience of this refresh token, in DID */
40
+ /** intended audience of this refresh token, in DID */
35
41
  aud: At.Did;
36
- /** Expiration time */
42
+ /** token expiration time in seconds */
37
43
  exp: number;
38
- /** Creation/issued time */
44
+ /** token issued time in seconds */
39
45
  iat: number;
40
46
  }
41
47
 
42
- /** Saved session data, this can be reused again for next time. */
48
+ /** session data, can be persisted and reused */
43
49
  export interface AtpSessionData {
44
- /** Refresh token */
50
+ /** refresh token */
45
51
  refreshJwt: string;
46
- /** Access token */
52
+ /** access token */
47
53
  accessJwt: string;
48
- /** Account handle */
54
+ /** account handle */
49
55
  handle: string;
50
- /** Account DID */
56
+ /** account DID */
51
57
  did: At.Did;
52
58
  /** PDS endpoint found in the DID document, this will be used as the service URI if provided */
53
59
  pdsUri?: string;
54
- /** Email address of the account, might not be available if on app password */
60
+ /** email address of the account, might not be available if on app password */
55
61
  email?: string;
56
- /** If the email address has been confirmed or not */
62
+ /** whether the email address has been confirmed or not */
57
63
  emailConfirmed?: boolean;
58
- /** If the account has email-based two-factor authentication enabled */
64
+ /** whether the account has email-based two-factor authentication enabled */
59
65
  emailAuthFactor?: boolean;
60
- /** Whether the account is active (not deactivated, taken down, or suspended) */
66
+ /** whether the account is active (not deactivated, taken down, or suspended) */
61
67
  active: boolean;
62
- /** Possible reason for why the account is inactive */
68
+ /** possible reason for why the account is inactive */
63
69
  inactiveStatus?: string;
64
70
  }
65
71
 
@@ -67,29 +73,36 @@ export interface CredentialManagerOptions {
67
73
  /** PDS server URL */
68
74
  service: string;
69
75
 
70
- /** Custom fetch function */
71
- fetch?: typeof globalThis.fetch;
76
+ /** custom fetch function */
77
+ fetch?: typeof fetch;
72
78
 
73
- /** Function that gets called if the session turned out to have expired during an XRPC request */
79
+ /** function called when the session expires and can't be refreshed */
74
80
  onExpired?: (session: AtpSessionData) => void;
75
- /** Function that gets called if the session has been refreshed during an XRPC request */
81
+ /** function called after a successful session refresh */
76
82
  onRefresh?: (session: AtpSessionData) => void;
77
- /** Function that gets called if the session object has been refreshed */
83
+ /** function called whenever the session object is updated (login, resume, refresh) */
78
84
  onSessionUpdate?: (session: AtpSessionData) => void;
79
85
  }
80
86
 
81
87
  export class CredentialManager implements FetchHandlerObject {
88
+ /** service URL to make authentication requests with */
82
89
  readonly serviceUrl: string;
90
+ /** fetch implementation */
83
91
  fetch: typeof fetch;
84
92
 
85
- #server: XRPC;
93
+ /** internal client instance for making authentication requests */
94
+ #server: Client;
95
+ /** holds a promise for the current refresh operation, used for debouncing */
86
96
  #refreshSessionPromise: Promise<void> | undefined;
87
97
 
98
+ /** callback for session expiration */
88
99
  #onExpired: CredentialManagerOptions['onExpired'];
100
+ /** callback for successful session refresh */
89
101
  #onRefresh: CredentialManagerOptions['onRefresh'];
102
+ /** callback for session updates */
90
103
  #onSessionUpdate: CredentialManagerOptions['onSessionUpdate'];
91
104
 
92
- /** Current session state */
105
+ /** current active session, undefined if not authenticated */
93
106
  session?: AtpSessionData;
94
107
 
95
108
  constructor({
@@ -102,13 +115,14 @@ export class CredentialManager implements FetchHandlerObject {
102
115
  this.serviceUrl = service;
103
116
  this.fetch = _fetch;
104
117
 
105
- this.#server = new XRPC({ handler: simpleFetchHandler({ service: service, fetch: _fetch }) });
118
+ this.#server = new Client({ handler: simpleFetchHandler({ service, fetch: _fetch }) });
106
119
 
107
120
  this.#onRefresh = onRefresh;
108
121
  this.#onExpired = onExpired;
109
122
  this.#onSessionUpdate = onSessionUpdate;
110
123
  }
111
124
 
125
+ /** service URL to make actual API requests with */
112
126
  get dispatchUrl() {
113
127
  return this.session?.pdsUri ?? this.serviceUrl;
114
128
  }
@@ -138,13 +152,14 @@ export class CredentialManager implements FetchHandlerObject {
138
152
  return initialResponse;
139
153
  }
140
154
 
141
- // Return initial response if:
142
- // - refreshSession returns expired
143
- // - Body stream has been consumed
155
+ // return initial response if:
156
+ // - the above refreshSession failed and cleared the session
157
+ // - provided request body was a stream, which can't be resent once consumed
144
158
  if (!this.session || init.body instanceof ReadableStream) {
145
159
  return initialResponse;
146
160
  }
147
161
 
162
+ // set the new token and retry the request
148
163
  headers.set('authorization', `Bearer ${this.session.accessJwt}`);
149
164
 
150
165
  return await (0, this.fetch)(url, { ...init, headers });
@@ -158,30 +173,29 @@ export class CredentialManager implements FetchHandlerObject {
158
173
 
159
174
  async #refreshSessionInner(): Promise<void> {
160
175
  const currentSession = this.session;
161
-
162
176
  if (!currentSession) {
163
177
  return;
164
178
  }
165
179
 
166
- try {
167
- const { data } = await this.#server.call('com.atproto.server.refreshSession', {
168
- headers: {
169
- authorization: `Bearer ${currentSession.refreshJwt}`,
170
- },
171
- });
172
-
173
- this.#updateSession({ ...currentSession, ...data });
174
- this.#onRefresh?.(this.session!);
175
- } catch (err) {
176
- if (err instanceof XRPCError) {
177
- const kind = err.kind;
178
-
179
- if (kind === 'ExpiredToken' || kind === 'InvalidToken') {
180
- this.session = undefined;
181
- this.#onExpired?.(currentSession);
182
- }
180
+ const response = await this.#server.post('com.atproto.server.refreshSession', {
181
+ headers: {
182
+ authorization: `Bearer ${currentSession.refreshJwt}`,
183
+ },
184
+ });
185
+
186
+ if (!response.ok) {
187
+ const error = response.data.error;
188
+
189
+ if (error === 'ExpiredToken' || error === 'InvalidToken') {
190
+ this.session = undefined;
191
+ this.#onExpired?.(currentSession);
183
192
  }
193
+
194
+ throw new ClientResponseError(response);
184
195
  }
196
+
197
+ this.#updateSession({ ...currentSession, ...response.data });
198
+ this.#onRefresh?.(this.session!);
185
199
  }
186
200
 
187
201
  #updateSession(raw: ComAtprotoServerCreateSession.Output): AtpSessionData {
@@ -192,7 +206,7 @@ export class CredentialManager implements FetchHandlerObject {
192
206
  pdsUri = getPdsEndpoint(didDoc);
193
207
  }
194
208
 
195
- const newSession = {
209
+ const newSession: AtpSessionData = {
196
210
  accessJwt: raw.accessJwt,
197
211
  refreshJwt: raw.refreshJwt,
198
212
  handle: raw.handle,
@@ -200,7 +214,7 @@ export class CredentialManager implements FetchHandlerObject {
200
214
  pdsUri: pdsUri,
201
215
  email: raw.email,
202
216
  emailConfirmed: raw.emailConfirmed,
203
- emailAuthFactor: raw.emailConfirmed,
217
+ emailAuthFactor: raw.emailAuthFactor,
204
218
  active: raw.active ?? true,
205
219
  inactiveStatus: raw.status,
206
220
  };
@@ -212,16 +226,16 @@ export class CredentialManager implements FetchHandlerObject {
212
226
  }
213
227
 
214
228
  /**
215
- * Resume a saved session
216
- * @param session Session information, taken from `AtpAuth#session` after login
229
+ * resume from a persisted session
230
+ * @param session session data, taken from `AtpAuth#session` after login
217
231
  */
218
232
  async resume(session: AtpSessionData): Promise<AtpSessionData> {
219
- const now = Date.now() / 1000 + 60 * 5;
233
+ const now = Date.now() / 1_000 + 60 * 5;
220
234
 
221
235
  const refreshToken = decodeJwt(session.refreshJwt) as AtpRefreshJwt;
222
236
 
223
237
  if (now >= refreshToken.exp) {
224
- throw new XRPCError(401, { kind: 'InvalidToken' });
238
+ throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
225
239
  }
226
240
 
227
241
  const accessToken = decodeJwt(session.accessJwt) as AtpAccessJwt;
@@ -230,62 +244,69 @@ export class CredentialManager implements FetchHandlerObject {
230
244
  if (now >= accessToken.exp) {
231
245
  await this.#refreshSession();
232
246
  } else {
233
- const promise = this.#server.get('com.atproto.server.getSession', {
234
- headers: {
235
- authorization: `Bearer ${session.accessJwt}`,
247
+ const promise = ok(
248
+ this.#server.get('com.atproto.server.getSession', {
249
+ headers: {
250
+ authorization: `Bearer ${session.accessJwt}`,
251
+ },
252
+ }),
253
+ );
254
+
255
+ promise.then(
256
+ (next) => {
257
+ const existing = this.session;
258
+ if (!existing || existing.did !== next.did) {
259
+ return;
260
+ }
261
+
262
+ this.#updateSession({ ...existing, ...next });
236
263
  },
237
- });
238
-
239
- promise.then((response) => {
240
- const existing = this.session;
241
- const next = response.data;
242
-
243
- if (!existing) {
244
- return;
245
- }
246
-
247
- this.#updateSession({ ...existing, ...next });
248
- });
264
+ (_err) => {
265
+ // ignore error
266
+ },
267
+ );
249
268
  }
250
269
 
251
270
  if (!this.session) {
252
- throw new XRPCError(401, { kind: 'InvalidToken' });
271
+ throw new ClientResponseError({ status: 401, data: { error: 'InvalidToken' } });
253
272
  }
254
273
 
255
274
  return this.session;
256
275
  }
257
276
 
258
277
  /**
259
- * Perform a login operation
260
- * @param options Login options
261
- * @returns Session data that can be saved for later
278
+ * sign in to an account
279
+ * @param options credential options
280
+ * @returns session data
262
281
  */
263
282
  async login(options: AuthLoginOptions): Promise<AtpSessionData> {
264
283
  // Reset the session
265
284
  this.session = undefined;
266
285
 
267
- const res = await this.#server.call('com.atproto.server.createSession', {
268
- data: {
269
- identifier: options.identifier,
270
- password: options.password,
271
- authFactorToken: options.code,
272
- allowTakendown: options.allowTakendown,
273
- },
274
- });
286
+ const session = await ok(
287
+ this.#server.post('com.atproto.server.createSession', {
288
+ input: {
289
+ identifier: options.identifier,
290
+ password: options.password,
291
+ authFactorToken: options.code,
292
+ allowTakendown: options.allowTakendown,
293
+ },
294
+ }),
295
+ );
275
296
 
276
- return this.#updateSession(res.data);
297
+ return this.#updateSession(session);
277
298
  }
278
299
  }
279
300
 
280
- /** Login options */
301
+ /** credentials */
281
302
  export interface AuthLoginOptions {
282
- /** What account to login as, this could be domain handle, DID, or email address */
303
+ /** what account to login as, this could be domain handle, DID, or email address */
283
304
  identifier: string;
284
- /** Account password */
305
+ /** account password */
285
306
  password: string;
286
- /** Two-factor authentication code */
307
+ /** two-factor authentication code, if email TOTP is enabled */
287
308
  code?: string;
288
- /** Allow signing in even if the account has been taken down, */
309
+ /** allow signing in even if the account has been taken down */
289
310
  allowTakendown?: boolean;
290
311
  }
291
312
 
@@ -298,6 +319,9 @@ const isExpiredTokenResponse = async (response: Response): Promise<boolean> => {
298
319
  return false;
299
320
  }
300
321
 
322
+ // this is nasty as it relies heavily on what the PDS returns, but avoiding
323
+ // cloning and reading the request as much as possible is better.
324
+
301
325
  // {"error":"ExpiredToken","message":"Token has expired"}
302
326
  // {"error":"ExpiredToken","message":"Token is expired"}
303
327
  if (extractContentLength(response.headers) > 54 * 1.5) {
@@ -305,8 +329,10 @@ const isExpiredTokenResponse = async (response: Response): Promise<boolean> => {
305
329
  }
306
330
 
307
331
  try {
308
- const { error, message } = await response.clone().json();
309
- return error === 'ExpiredToken' && (typeof message === 'string' || message === undefined);
332
+ const data = await response.clone().json();
333
+ if (isXRPCErrorPayload(data)) {
334
+ return data.error === 'ExpiredToken';
335
+ }
310
336
  } catch {}
311
337
 
312
338
  return false;
@@ -1,7 +1,7 @@
1
- /** Fetch handler function */
1
+ /** fetch handler function */
2
2
  export type FetchHandler = (pathname: string, init: RequestInit) => Promise<Response>;
3
3
 
4
- /** Fetch handler in an object */
4
+ /** fetch handler in an object */
5
5
  export interface FetchHandlerObject {
6
6
  handle(this: FetchHandlerObject, pathname: string, init: RequestInit): Promise<Response>;
7
7
  }
@@ -25,6 +25,6 @@ export const simpleFetchHandler = ({
25
25
  }: SimpleFetchHandlerOptions): FetchHandler => {
26
26
  return async (pathname, init) => {
27
27
  const url = new URL(pathname, service);
28
- return _fetch(url, init);
28
+ return await _fetch(url, init);
29
29
  };
30
30
  };
package/lib/index.ts CHANGED
@@ -1,3 +1,4 @@
1
- export * from './rpc.js';
2
- export * from './fetch-handler.js';
1
+ export * from './client.js';
3
2
  export * from './credential-manager.js';
3
+ export * from './fetch-handler.js';
4
+ export * from './rpc.js';
package/lib/rpc.ts CHANGED
@@ -3,15 +3,24 @@ import type { At, Procedures, Queries } from './lexicons.js';
3
3
  import { buildFetchHandler, type FetchHandler, type FetchHandlerObject } from './fetch-handler.js';
4
4
  import { mergeHeaders } from './utils/http.js';
5
5
 
6
+ /**
7
+ * @deprecated
8
+ */
6
9
  export type HeadersObject = Record<string, string>;
7
10
 
8
- /** Response from XRPC service */
11
+ /**
12
+ * Response from XRPC service
13
+ * @deprecated
14
+ */
9
15
  export interface XRPCResponse<T = any> {
10
16
  data: T;
11
17
  headers: HeadersObject;
12
18
  }
13
19
 
14
- /** Options for constructing an XRPC error */
20
+ /**
21
+ * Options for constructing an XRPC error
22
+ * @deprecated
23
+ */
15
24
  export interface XRPCErrorOptions {
16
25
  kind?: string;
17
26
  description?: string;
@@ -19,7 +28,10 @@ export interface XRPCErrorOptions {
19
28
  cause?: unknown;
20
29
  }
21
30
 
22
- /** Error coming from the XRPC service */
31
+ /**
32
+ * Error coming from the XRPC service
33
+ * @deprecated
34
+ */
23
35
  export class XRPCError extends Error {
24
36
  override name = 'XRPCError';
25
37
 
@@ -50,19 +62,28 @@ export class XRPCError extends Error {
50
62
  }
51
63
  }
52
64
 
53
- /** Service proxy options */
65
+ /**
66
+ * Service proxy options
67
+ * @deprecated
68
+ */
54
69
  export interface XRPCProxyOptions {
55
70
  type: 'atproto_pds' | 'atproto_labeler' | 'bsky_fg' | 'bsky_notif' | ({} & string);
56
71
  service: At.Did;
57
72
  }
58
73
 
59
- /** Options for constructing an XRPC */
74
+ /**
75
+ * Options for constructing an XRPC
76
+ * @deprecated
77
+ */
60
78
  export interface XRPCOptions {
61
79
  handler: FetchHandler | FetchHandlerObject;
62
80
  proxy?: XRPCProxyOptions;
63
81
  }
64
82
 
65
- /** XRPC request options */
83
+ /**
84
+ * XRPC request options
85
+ * @deprecated
86
+ */
66
87
  export interface XRPCRequestOptions {
67
88
  type: 'get' | 'post';
68
89
  nsid: string;
@@ -72,7 +93,10 @@ export interface XRPCRequestOptions {
72
93
  signal?: AbortSignal;
73
94
  }
74
95
 
75
- /** XRPC response */
96
+ /**
97
+ * XRPC response
98
+ * @deprecated
99
+ */
76
100
  export interface XRPCResponse<T = any> {
77
101
  data: T;
78
102
  headers: HeadersObject;
@@ -86,13 +110,19 @@ interface BaseRPCOptions {
86
110
  signal?: AbortSignal;
87
111
  }
88
112
 
89
- /** Options for the query/procedure request */
113
+ /**
114
+ * Options for the query/procedure request
115
+ * @deprecated
116
+ */
90
117
  export type RPCOptions<T> = BaseRPCOptions &
91
118
  (T extends { params: any } ? { params: T['params'] } : {}) &
92
119
  (T extends { input: any } ? { data: T['input'] } : {});
93
120
 
94
121
  type OutputOf<T> = T extends { output: any } ? T['output'] : never;
95
122
 
123
+ /**
124
+ * @deprecated
125
+ */
96
126
  export class XRPC {
97
127
  handle: FetchHandler;
98
128
  proxy: XRPCProxyOptions | undefined;
@@ -253,10 +283,16 @@ interface ErrorResponseBody {
253
283
  message?: string;
254
284
  }
255
285
 
286
+ /**
287
+ * @deprecated
288
+ */
256
289
  export const clone = (rpc: XRPC): XRPC => {
257
290
  return new XRPC({ handler: rpc.handle, proxy: rpc.proxy });
258
291
  };
259
292
 
293
+ /**
294
+ * @deprecated
295
+ */
260
296
  export const withProxy = (rpc: XRPC, options: XRPCProxyOptions) => {
261
297
  return new XRPC({ handler: rpc.handle, proxy: options });
262
298
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@atcute/client",
4
- "version": "3.0.1",
4
+ "version": "3.1.0",
5
5
  "description": "lightweight and cute API client for AT Protocol",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -25,7 +25,7 @@
25
25
  "@vitest/coverage-v8": "^3.0.4",
26
26
  "vitest": "^3.0.4",
27
27
  "@atcute/internal-dev-env": "^1.0.1",
28
- "@atcute/lex-cli": "^1.1.0"
28
+ "@atcute/lex-cli": "^1.1.2"
29
29
  },
30
30
  "scripts": {
31
31
  "build": "tsc --project tsconfig.build.json",