@crosspost/sdk 0.1.8 → 0.1.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crosspost/sdk",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "SDK for interacting with the Crosspost API",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -36,6 +36,7 @@
36
36
  "author": "crosspost.near",
37
37
  "license": "MIT",
38
38
  "dependencies": {
39
+ "@crosspost/types": "^0.1.9",
39
40
  "near-sign-verify": "^0.1.3"
40
41
  },
41
42
  "devDependencies": {
package/src/api/auth.ts CHANGED
@@ -5,7 +5,6 @@ import type {
5
5
  AuthStatusParams,
6
6
  AuthStatusResponse,
7
7
  AuthTokenRequest,
8
- AuthUrlResponse,
9
8
  ConnectedAccount,
10
9
  ConnectedAccountsResponse,
11
10
  NearAuthorizationRequest,
@@ -14,6 +13,7 @@ import type {
14
13
  Platform,
15
14
  } from '@crosspost/types';
16
15
  import { makeRequest, type RequestOptions } from '../core/request.ts';
16
+ import { openAuthPopup } from '../utils/popup.ts';
17
17
 
18
18
  /**
19
19
  * Authentication-related API operations
@@ -55,22 +55,40 @@ export class AuthApi {
55
55
  }
56
56
 
57
57
  /**
58
- * Initiates the login process for a specific platform.
59
- * The service handles the OAuth flow; this method triggers it.
58
+ * Initiates the login process for a specific platform using a popup window.
60
59
  * @param platform The target platform.
61
60
  * @param options Optional success and error redirect URLs.
62
- * @returns A promise resolving with the response from the service (might indicate success/failure or redirect info).
61
+ * @returns Promise that resolves with the authentication result when the popup completes.
62
+ * @throws Error if popups are blocked or if running in a non-browser environment.
63
63
  */
64
64
  async loginToPlatform(
65
65
  platform: Platform,
66
66
  options?: AuthInitRequest,
67
- ): Promise<AuthUrlResponse> {
68
- return makeRequest<AuthUrlResponse, AuthInitRequest>(
69
- 'POST',
70
- `/auth/${platform}/login`,
71
- this.options,
72
- options || {},
73
- );
67
+ ): Promise<AuthCallbackResponse> {
68
+ // Construct the login URL
69
+ const baseUrl = this.options.baseUrl || '';
70
+ const loginUrl = new URL(`/auth/${platform}/login`, baseUrl);
71
+
72
+ // Add successUrl and errorUrl if provided
73
+ if (options?.successUrl) {
74
+ loginUrl.searchParams.set('successUrl', options.successUrl);
75
+ }
76
+ if (options?.errorUrl) {
77
+ loginUrl.searchParams.set('errorUrl', options.errorUrl);
78
+ }
79
+
80
+ // Open the popup and wait for the result
81
+ const result = await openAuthPopup(loginUrl.toString());
82
+
83
+ if (!result.success || !result.userId) {
84
+ throw new Error(result.error || 'Authentication failed');
85
+ }
86
+
87
+ // Return the result in the expected format
88
+ return {
89
+ platform,
90
+ userId: result.userId,
91
+ };
74
92
  }
75
93
 
76
94
  /**
@@ -146,7 +164,8 @@ export class AuthApi {
146
164
 
147
165
  /**
148
166
  * Lists all accounts connected to the NEAR account.
149
- * @returns A promise resolving with the list of connected accounts.
167
+ * @returns A promise resolving with the connected accounts response containing an array of accounts.
168
+ * @throws {CrosspostError} If the request fails or returns invalid data.
150
169
  */
151
170
  async getConnectedAccounts(): Promise<ConnectedAccountsResponse> {
152
171
  return makeRequest<ConnectedAccountsResponse, never>(
@@ -24,14 +24,12 @@ export class CrosspostClient {
24
24
  constructor(config: CrosspostClientConfig = {}) {
25
25
  const baseUrl = config.baseUrl || DEFAULT_CONFIG.baseUrl; // you can deploy your own
26
26
  const timeout = config.timeout || DEFAULT_CONFIG.timeout;
27
- const retries = config.retries ?? DEFAULT_CONFIG.retries;
28
27
 
29
28
  const nearAuthData = config.nearAuthData;
30
29
 
31
30
  this.options = {
32
31
  baseUrl,
33
32
  timeout,
34
- retries,
35
33
  nearAuthData,
36
34
  };
37
35
 
@@ -18,11 +18,6 @@ export interface CrosspostClientConfig {
18
18
  * @default 30000
19
19
  */
20
20
  timeout?: number;
21
- /**
22
- * Number of retries for failed requests (specifically for network errors or 5xx status codes)
23
- * @default 2
24
- */
25
- retries?: number;
26
21
  }
27
22
 
28
23
  /**
@@ -31,5 +26,4 @@ export interface CrosspostClientConfig {
31
26
  export const DEFAULT_CONFIG: Required<Omit<CrosspostClientConfig, 'nearAuthData'>> = {
32
27
  baseUrl: 'https://open-crosspost-proxy.deno.dev/',
33
28
  timeout: 30000,
34
- retries: 2,
35
29
  };
@@ -1,9 +1,9 @@
1
1
  import { ApiErrorCode, type StatusCode } from '@crosspost/types';
2
2
  import { createAuthToken, type NearAuthData } from 'near-sign-verify';
3
3
  import {
4
- apiWrapper,
5
4
  createNetworkError,
6
5
  CrosspostError,
6
+ enrichErrorWithContext,
7
7
  handleErrorResponse,
8
8
  } from '../utils/error.ts';
9
9
 
@@ -29,20 +29,23 @@ export interface RequestOptions {
29
29
  * Request timeout in milliseconds
30
30
  */
31
31
  timeout: number;
32
- /**
33
- * Number of retries for failed requests
34
- */
35
- retries: number;
36
32
  }
37
33
 
38
34
  /**
39
- * Makes a request to the API with retry and error handling
35
+ * Makes a request to the API with error handling and data extraction
40
36
  *
41
37
  * @param method The HTTP method
42
38
  * @param path The API path
43
39
  * @param options The request options
44
40
  * @param data Optional request data
45
- * @returns A promise resolving with the response data
41
+ * @param query Optional query parameters
42
+ * @returns A promise resolving with the data field from the API response
43
+ * @throws {CrosspostError}
44
+ * - If the request fails (network error, timeout)
45
+ * - If the response is not valid JSON
46
+ * - If the response does not follow the expected ApiResponse format
47
+ * - If the response indicates success but contains no data
48
+ * - If the response indicates failure (includes error details and metadata)
46
49
  */
47
50
  export async function makeRequest<
48
51
  TResponse,
@@ -76,131 +79,126 @@ export async function makeRequest<
76
79
  method,
77
80
  path,
78
81
  url,
79
- retries: options.retries,
80
82
  };
81
83
 
82
- return apiWrapper(async () => {
83
- let lastError: Error | null = null;
84
-
85
- for (let attempt = 0; attempt <= options.retries; attempt++) {
86
- const controller = new AbortController();
87
- const timeoutId = setTimeout(() => controller.abort(), options.timeout);
84
+ const controller = new AbortController();
85
+ const timeoutId = setTimeout(() => controller.abort(), options.timeout);
86
+
87
+ try {
88
+ const headers: Record<string, string> = {
89
+ 'Content-Type': 'application/json',
90
+ 'Accept': 'application/json',
91
+ };
92
+
93
+ // For GET requests, use X-Near-Account header if available
94
+ if (method === 'GET') {
95
+ const nearAccount = options.nearAccount || options.nearAuthData?.account_id;
96
+ if (!nearAccount) {
97
+ throw new CrosspostError(
98
+ 'No NEAR account provided for GET request',
99
+ ApiErrorCode.UNAUTHORIZED,
100
+ 401,
101
+ );
102
+ }
103
+ headers['X-Near-Account'] = nearAccount;
104
+ } else {
105
+ // For non-GET requests, require nearAuthData
106
+ if (!options.nearAuthData) {
107
+ throw new CrosspostError(
108
+ 'NEAR authentication data required for non-GET request',
109
+ ApiErrorCode.UNAUTHORIZED,
110
+ 401,
111
+ );
112
+ }
113
+ headers['Authorization'] = `Bearer ${createAuthToken(options.nearAuthData)}`;
114
+ }
88
115
 
116
+ const requestOptions: RequestInit = {
117
+ method,
118
+ headers,
119
+ body: method !== 'GET' && data ? JSON.stringify(data) : undefined,
120
+ signal: controller.signal,
121
+ };
122
+
123
+ const response = await fetch(url, requestOptions);
124
+ clearTimeout(timeoutId);
125
+
126
+ let responseData: any;
127
+ try {
128
+ responseData = await response.json();
129
+ } catch (jsonError) {
130
+ // JSON parsing failed - try to get response text for context
131
+ let responseText: string | undefined;
89
132
  try {
90
- const headers: Record<string, string> = {
91
- 'Content-Type': 'application/json',
92
- 'Accept': 'application/json',
93
- };
94
-
95
- // For GET requests, use X-Near-Account header if available
96
- if (method === 'GET') {
97
- const nearAccount = options.nearAccount || options.nearAuthData?.account_id;
98
- if (!nearAccount) {
99
- throw new CrosspostError(
100
- 'No NEAR account provided for GET request',
101
- ApiErrorCode.UNAUTHORIZED,
102
- 401,
103
- );
104
- }
105
- headers['X-Near-Account'] = nearAccount;
106
- } else {
107
- // For non-GET requests, require nearAuthData
108
- if (!options.nearAuthData) {
109
- throw new CrosspostError(
110
- 'NEAR authentication data required for non-GET request',
111
- ApiErrorCode.UNAUTHORIZED,
112
- 401,
113
- );
114
- }
115
- headers['Authorization'] = `Bearer ${createAuthToken(options.nearAuthData)}`;
116
- }
117
-
118
- const requestOptions: RequestInit = {
119
- method,
120
- headers,
121
- body: method !== 'GET' && data ? JSON.stringify(data) : undefined,
122
- signal: controller.signal,
123
- };
124
-
125
- const response = await fetch(url, requestOptions);
126
- clearTimeout(timeoutId); // Clear timeout if fetch completes
127
-
128
- let responseData: any;
129
- try {
130
- responseData = await response.json();
131
- } catch (jsonError) {
132
- // If JSON parsing fails, did API throw an error?
133
- if (!response.ok) {
134
- throw new CrosspostError(
135
- `API request failed with status ${response.status} and non-JSON response`,
136
- ApiErrorCode.NETWORK_ERROR,
137
- response.status as StatusCode,
138
- { originalStatusText: response.statusText },
139
- );
140
- }
141
- // Otherwise, throw a custom error
142
- throw new CrosspostError(
143
- `Failed to parse JSON response: ${
144
- jsonError instanceof Error ? jsonError.message : String(jsonError)
145
- }`,
146
- ApiErrorCode.INTERNAL_ERROR,
147
- response.status as StatusCode,
148
- );
149
- }
150
-
151
- if (!response.ok) {
152
- lastError = handleErrorResponse(responseData, response.status);
153
- // Only retry rate limit errors
154
- const shouldRetry = lastError instanceof CrosspostError &&
155
- lastError.code === ApiErrorCode.RATE_LIMITED;
156
- if (shouldRetry && attempt < options.retries) {
157
- await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); // Exponential backoff
158
- continue; // Retry
159
- }
160
- throw lastError; // Throw error if not retrying or retries exhausted
161
- }
162
-
163
- // Handle response based on success flag
164
- if (responseData && typeof responseData === 'object' && 'success' in responseData) {
165
- if (responseData.success) {
166
- // Success response - return the data
167
- return responseData.data as TResponse;
168
- } else {
169
- // Error response - handle with our error utilities
170
- lastError = handleErrorResponse(responseData, response.status);
171
- // Only retry rate limit errors
172
- const shouldRetry = lastError instanceof CrosspostError &&
173
- lastError.code === ApiErrorCode.RATE_LIMITED;
174
- if (shouldRetry && attempt < options.retries) {
175
- await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); // Exponential backoff
176
- continue; // Retry
177
- }
178
- throw lastError;
179
- }
180
- }
181
- } catch (error) {
182
- clearTimeout(timeoutId); // Clear timeout on error
183
- lastError = error instanceof Error ? error : new Error(String(error)); // Store the error
184
-
185
- // Handle fetch/network errors specifically for retries
186
- const isNetworkError = error instanceof TypeError ||
187
- (error instanceof DOMException && error.name === 'AbortError');
188
- if (isNetworkError && attempt < options.retries) {
189
- await new Promise((resolve) => setTimeout(resolve, 1000 * Math.pow(2, attempt))); // Exponential backoff
190
- continue; // Retry network error
191
- }
192
-
193
- // If it's not a known ApiError/PlatformError, wrap it
194
- if (!(error instanceof CrosspostError)) {
195
- throw createNetworkError(error, url, options.timeout);
196
- }
197
-
198
- throw error; // Re-throw known ApiError or final network error
133
+ responseText = await response.text();
134
+ } catch (_) { /* ignore */ }
135
+
136
+ throw new CrosspostError(
137
+ `API request failed with status ${response.status} and non-JSON response`,
138
+ ApiErrorCode.INVALID_RESPONSE,
139
+ response.status as StatusCode,
140
+ {
141
+ originalStatusText: response.statusText,
142
+ originalError: jsonError instanceof Error ? jsonError.message : String(jsonError),
143
+ responseText,
144
+ },
145
+ );
146
+ }
147
+
148
+ // Handle non-ok responses (4xx/5xx)
149
+ if (!response.ok) {
150
+ throw handleErrorResponse(responseData, response.status);
151
+ }
152
+
153
+ // Validate success response structure
154
+ if (!responseData || typeof responseData !== 'object' || !('success' in responseData)) {
155
+ throw new CrosspostError(
156
+ 'Invalid success response format from API',
157
+ ApiErrorCode.INVALID_RESPONSE,
158
+ response.status as StatusCode,
159
+ { responseData },
160
+ );
161
+ }
162
+
163
+ if (responseData.success) {
164
+ if (!responseData.data) {
165
+ throw new CrosspostError(
166
+ 'API returned success but no data',
167
+ ApiErrorCode.INVALID_RESPONSE,
168
+ response.status as StatusCode,
169
+ { responseData },
170
+ );
199
171
  }
172
+ return responseData.data as TResponse;
200
173
  }
201
174
 
202
- // Should not be reachable if retries >= 0, but needed for type safety
203
- throw lastError ||
204
- new CrosspostError('Request failed after multiple retries', ApiErrorCode.INTERNAL_ERROR, 500);
205
- }, context);
175
+ // If we get here, we have response.ok but success: false
176
+ // This is unexpected - treat as an error
177
+ throw handleErrorResponse(responseData, response.status);
178
+ } catch (error) {
179
+ clearTimeout(timeoutId);
180
+
181
+ if (error instanceof CrosspostError) {
182
+ // Enrich CrosspostError with request context
183
+ throw enrichErrorWithContext(error, context);
184
+ }
185
+
186
+ // Handle network errors (including timeouts)
187
+ if (
188
+ error instanceof TypeError || (error instanceof DOMException && error.name === 'AbortError')
189
+ ) {
190
+ throw enrichErrorWithContext(createNetworkError(error, url, options.timeout), context);
191
+ }
192
+
193
+ // For any other errors, wrap them with context
194
+ throw enrichErrorWithContext(
195
+ new CrosspostError(
196
+ error instanceof Error ? error.message : String(error),
197
+ ApiErrorCode.INTERNAL_ERROR,
198
+ 500,
199
+ { originalError: String(error) },
200
+ ),
201
+ context,
202
+ );
203
+ }
206
204
  }
@@ -0,0 +1,129 @@
1
+ import type { PlatformName } from '@crosspost/types';
2
+
3
+ // Augment the Window interface
4
+ declare global {
5
+ interface Window {
6
+ innerWidth: number;
7
+ innerHeight: number;
8
+ open(url: string, target: string, features: string): Window | null;
9
+ }
10
+
11
+ interface WindowEventMap {
12
+ message: MessageEvent<AuthCallbackMessage>;
13
+ }
14
+ }
15
+
16
+ interface PopupOptions {
17
+ width?: number;
18
+ height?: number;
19
+ left?: number;
20
+ top?: number;
21
+ }
22
+
23
+ interface AuthCallbackData {
24
+ success: boolean;
25
+ platform: PlatformName;
26
+ userId?: string;
27
+ error?: string;
28
+ error_description?: string;
29
+ }
30
+
31
+ interface AuthCallbackMessage {
32
+ type: 'AUTH_CALLBACK';
33
+ data: AuthCallbackData;
34
+ }
35
+
36
+ /**
37
+ * Opens a popup window and returns a promise that resolves when the authentication is complete
38
+ * @param url The URL to open in the popup
39
+ * @param options Optional popup window dimensions and position
40
+ * @returns Promise that resolves with the authentication result
41
+ * @throws Error if popups are blocked or if running in a non-browser environment
42
+ */
43
+ export function openAuthPopup(url: string, options: PopupOptions = {}): Promise<AuthCallbackData> {
44
+ // Check for browser environment
45
+ if (typeof window === 'undefined') {
46
+ throw new Error('openAuthPopup can only be used in a browser environment');
47
+ }
48
+
49
+ return new Promise((resolve, reject) => {
50
+ // Calculate popup dimensions and position
51
+ const {
52
+ width = 600,
53
+ height = 700,
54
+ left = Math.max(0, (window.innerWidth - 600) / 2),
55
+ top = Math.max(0, (window.innerHeight - 700) / 2),
56
+ } = options;
57
+
58
+ // Open the popup
59
+ const popup = window.open(
60
+ url,
61
+ 'authPopup',
62
+ `width=${width},height=${height},left=${left},top=${top},scrollbars=yes`,
63
+ );
64
+
65
+ if (!popup) {
66
+ reject(new Error('Popup blocked. Please allow popups for this site.'));
67
+ return;
68
+ }
69
+
70
+ let messageReceived = false;
71
+
72
+ // Function to handle messages from the popup with proper typing
73
+ const handleMessage = (event: MessageEvent<AuthCallbackMessage>) => {
74
+ // Verify the message is from our popup and popup exists
75
+ if (!popup || event.source !== popup) {
76
+ return;
77
+ }
78
+
79
+ const message = event.data;
80
+ if (message?.type === 'AUTH_CALLBACK') {
81
+ messageReceived = true;
82
+ window.removeEventListener('message', handleMessage);
83
+ clearInterval(checkClosedInterval);
84
+
85
+ if (message.data.success) {
86
+ resolve(message.data);
87
+ } else {
88
+ reject(message.data);
89
+ }
90
+
91
+ // Give a moment for any final operations before closing
92
+ setTimeout(() => {
93
+ try {
94
+ if (popup && !popup.closed) {
95
+ popup.close();
96
+ }
97
+ } catch (e) {
98
+ console.warn('Failed to close popup window:', e);
99
+ }
100
+ }, 100);
101
+ }
102
+ };
103
+
104
+ // Listen for messages from the popup
105
+ window.addEventListener('message', handleMessage as EventListener);
106
+
107
+ // Check if popup was closed manually
108
+ const checkClosedInterval = setInterval(() => {
109
+ try {
110
+ if (!popup || popup.closed) {
111
+ cleanup();
112
+ }
113
+ } catch (e) {
114
+ console.warn('Error checking popup state:', e);
115
+ cleanup();
116
+ }
117
+ }, 500);
118
+
119
+ // Cleanup function to handle popup closure
120
+ function cleanup() {
121
+ clearInterval(checkClosedInterval);
122
+ window.removeEventListener('message', handleMessage as EventListener);
123
+
124
+ if (!messageReceived) {
125
+ reject(new Error('Authentication cancelled by user.'));
126
+ }
127
+ }
128
+ });
129
+ }