@bentonow/bento-node-sdk 1.0.4 → 1.0.5

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,6 +1,15 @@
1
1
  import fetch from 'cross-fetch';
2
2
  import type { AnalyticsOptions, AuthenticationOptions } from '../interfaces';
3
- import { NotAuthorizedError, RateLimitedError, AuthorNotAuthorizedError } from './errors';
3
+ import {
4
+ NotAuthorizedError,
5
+ RateLimitedError,
6
+ AuthorNotAuthorizedError,
7
+ RequestTimeoutError,
8
+ } from './errors';
9
+
10
+ interface RequestOptions {
11
+ timeout?: number | null;
12
+ }
4
13
 
5
14
  function encodeBase64(str: string): string {
6
15
  if (typeof btoa === 'function') {
@@ -29,17 +38,20 @@ function encodeBase64(str: string): string {
29
38
  return output;
30
39
  }
31
40
  }
41
+
32
42
  export class BentoClient {
33
43
  private readonly _headers: HeadersInit = {};
34
44
  private readonly _baseUrl: string = 'https://app.bentonow.com/api/v1';
35
45
  private readonly _siteUuid: string = '';
36
46
  private readonly _logErrors: boolean = false;
47
+ private readonly _timeout: number = 30000; // 30 seconds default
37
48
 
38
49
  constructor(options: AnalyticsOptions) {
39
50
  this._baseUrl = options.clientOptions?.baseUrl || this._baseUrl;
40
51
  this._siteUuid = options.siteUuid;
41
52
  this._headers = this._extractHeaders(options.authentication, options.siteUuid);
42
53
  this._logErrors = options.logErrors || false;
54
+ this._timeout = options.clientOptions?.timeout ?? this._timeout;
43
55
  }
44
56
 
45
57
  /**
@@ -50,24 +62,26 @@ export class BentoClient {
50
62
  * @param payload object
51
63
  * @returns Promise\<T\>
52
64
  * */
53
- public get<T>(endpoint: string, payload: Record<string, unknown> = {}): Promise<T> {
54
- return new Promise<T>((resolve, reject) => {
55
- const queryParameters = this._getQueryParameters(payload);
65
+ public async get<T>(
66
+ endpoint: string,
67
+ payload: Record<string, unknown> = {},
68
+ requestOptions: RequestOptions = {}
69
+ ): Promise<T> {
70
+ const queryParameters = this._getQueryParameters(payload);
71
+ const url = `${this._baseUrl}${endpoint}?${queryParameters}`;
56
72
 
57
- fetch(`${this._baseUrl}${endpoint}?${queryParameters}`, {
73
+ const timeoutMs =
74
+ requestOptions.timeout === undefined ? this._timeout : requestOptions.timeout;
75
+ const response = await this._fetchWithTimeout(
76
+ url,
77
+ {
58
78
  method: 'GET',
59
79
  headers: this._headers,
60
- })
61
- .then(async (result) => {
62
- if (this._isSuccessfulStatus(result.status)) {
63
- return result.json();
64
- }
65
-
66
- throw await this._getErrorForResponse(result);
67
- })
68
- .then((data) => resolve(data))
69
- .catch((error) => reject(error));
70
- });
80
+ },
81
+ timeoutMs
82
+ );
83
+
84
+ return this._handleResponse<T>(response);
71
85
  }
72
86
 
73
87
  /**
@@ -78,28 +92,30 @@ export class BentoClient {
78
92
  * @param payload object
79
93
  * @returns Promise\<T\>
80
94
  * */
81
- public post<T>(endpoint: string, payload: Record<string, unknown> = {}): Promise<T> {
82
- return new Promise<T>((resolve, reject) => {
83
- const body = this._getBody(payload);
95
+ public async post<T>(
96
+ endpoint: string,
97
+ payload: Record<string, unknown> = {},
98
+ requestOptions: RequestOptions = {}
99
+ ): Promise<T> {
100
+ const body = this._getBody(payload);
101
+ const url = `${this._baseUrl}${endpoint}`;
84
102
 
85
- fetch(`${this._baseUrl}${endpoint}`, {
103
+ const timeoutMs =
104
+ requestOptions.timeout === undefined ? this._timeout : requestOptions.timeout;
105
+ const response = await this._fetchWithTimeout(
106
+ url,
107
+ {
86
108
  method: 'POST',
87
109
  headers: {
88
110
  ...this._headers,
89
111
  'Content-Type': 'application/json',
90
112
  },
91
113
  body,
92
- })
93
- .then(async (result) => {
94
- if (this._isSuccessfulStatus(result.status)) {
95
- return result.json();
96
- }
97
-
98
- throw await this._getErrorForResponse(result);
99
- })
100
- .then((data) => resolve(data))
101
- .catch((error) => reject(error));
102
- });
114
+ },
115
+ timeoutMs
116
+ );
117
+
118
+ return this._handleResponse<T>(response);
103
119
  }
104
120
 
105
121
  /**
@@ -110,28 +126,85 @@ export class BentoClient {
110
126
  * @param payload object
111
127
  * @returns Promise\<T\>
112
128
  * */
113
- public patch<T>(endpoint: string, payload: Record<string, unknown> = {}): Promise<T> {
114
- return new Promise<T>((resolve, reject) => {
115
- const body = this._getBody(payload);
129
+ public async patch<T>(
130
+ endpoint: string,
131
+ payload: Record<string, unknown> = {},
132
+ requestOptions: RequestOptions = {}
133
+ ): Promise<T> {
134
+ const body = this._getBody(payload);
135
+ const url = `${this._baseUrl}${endpoint}`;
116
136
 
117
- fetch(`${this._baseUrl}${endpoint}`, {
137
+ const timeoutMs =
138
+ requestOptions.timeout === undefined ? this._timeout : requestOptions.timeout;
139
+ const response = await this._fetchWithTimeout(
140
+ url,
141
+ {
118
142
  method: 'PATCH',
119
143
  headers: {
120
144
  ...this._headers,
121
145
  'Content-Type': 'application/json',
122
146
  },
123
147
  body,
124
- })
125
- .then(async (result) => {
126
- if (this._isSuccessfulStatus(result.status)) {
127
- return result.json();
128
- }
129
-
130
- throw await this._getErrorForResponse(result);
131
- })
132
- .then((data) => resolve(data))
133
- .catch((error) => reject(error));
134
- });
148
+ },
149
+ timeoutMs
150
+ );
151
+
152
+ return this._handleResponse<T>(response);
153
+ }
154
+
155
+ /**
156
+ * Performs a fetch request with a configurable timeout.
157
+ *
158
+ * @param url The URL to fetch
159
+ * @param options Fetch options
160
+ * @returns Promise<Response>
161
+ */
162
+ private async _fetchWithTimeout(
163
+ url: string,
164
+ options: RequestInit,
165
+ timeout: number | null
166
+ ): Promise<Response> {
167
+ if (timeout === null) {
168
+ return fetch(url, options);
169
+ }
170
+
171
+ const controller = new AbortController();
172
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
173
+
174
+ try {
175
+ const response = await fetch(url, {
176
+ ...options,
177
+ signal: controller.signal,
178
+ });
179
+ return response;
180
+ } catch (error: unknown) {
181
+ if (error instanceof Error && error.name === 'AbortError') {
182
+ throw new RequestTimeoutError(`Request timed out after ${timeout}ms`);
183
+ }
184
+ throw error;
185
+ } finally {
186
+ clearTimeout(timeoutId);
187
+ }
188
+ }
189
+
190
+ /**
191
+ * Handles the response from a fetch request, parsing JSON or throwing appropriate errors.
192
+ *
193
+ * @param response The fetch Response object
194
+ * @returns Promise<T> The parsed response data
195
+ */
196
+ private async _handleResponse<T>(response: Response): Promise<T> {
197
+ if (this._isSuccessfulStatus(response.status)) {
198
+ try {
199
+ const data = await response.json();
200
+ return data as T;
201
+ } catch {
202
+ // If JSON parsing fails on a successful response, throw a descriptive error
203
+ throw new Error(`[${response.status}] - Invalid JSON response from server`);
204
+ }
205
+ }
206
+
207
+ throw await this._getErrorForResponse(response);
135
208
  }
136
209
 
137
210
  /**
@@ -182,7 +255,7 @@ export class BentoClient {
182
255
 
183
256
  const queryParameters = new URLSearchParams();
184
257
  for (const [key, value] of Object.entries(body)) {
185
- queryParameters.append(key, value);
258
+ queryParameters.append(key, String(value));
186
259
  }
187
260
 
188
261
  return queryParameters.toString();
@@ -216,7 +289,7 @@ export class BentoClient {
216
289
 
217
290
  const contentType = response.headers.get('Content-Type');
218
291
  let responseMessage = '';
219
- let json: any = null;
292
+ let json: Record<string, unknown> | null = null;
220
293
 
221
294
  // Try to parse the response body based on content type
222
295
  try {
@@ -244,7 +317,7 @@ export class BentoClient {
244
317
 
245
318
  // Check for author not authorized error in JSON response
246
319
  if (json && json.error === 'Author not authorized to send on this account') {
247
- return new AuthorNotAuthorizedError(json.error);
320
+ return new AuthorNotAuthorizedError(json.error as string);
248
321
  }
249
322
 
250
323
  // If we have JSON but no specific error match, use the JSON string
@@ -12,4 +12,8 @@ export interface AuthenticationOptions {
12
12
 
13
13
  export interface ClientOptions {
14
14
  baseUrl?: string;
15
+ /**
16
+ * Request timeout in milliseconds. Defaults to 30000 (30 seconds).
17
+ */
18
+ timeout?: number;
15
19
  }
@@ -10,12 +10,12 @@ export class BentoSequences {
10
10
  /**
11
11
  * Returns all of the sequences for the site, including their email templates.
12
12
  *
13
- * @returns Promise\<Sequence[] | null\>
13
+ * @returns Promise\<Sequence[]\>
14
14
  */
15
- public async getSequences(): Promise<Sequence[] | null> {
15
+ public async getSequences(): Promise<Sequence[]> {
16
16
  const result = await this._client.get<DataResponse<Sequence[]>>(this._url);
17
17
 
18
- if (Object.keys(result).length === 0 || !result.data) return null;
19
- return result.data;
18
+ if (!result || Object.keys(result).length === 0) return [];
19
+ return result.data ?? [];
20
20
  }
21
21
  }
@@ -10,12 +10,12 @@ export class BentoWorkflows {
10
10
  /**
11
11
  * Returns all of the workflows for the site, including their email templates.
12
12
  *
13
- * @returns Promise\<Workflow[] | null\>
13
+ * @returns Promise\<Workflow[]\>
14
14
  */
15
- public async getWorkflows(): Promise<Workflow[] | null> {
15
+ public async getWorkflows(): Promise<Workflow[]> {
16
16
  const result = await this._client.get<DataResponse<Workflow[]>>(this._url);
17
17
 
18
- if (Object.keys(result).length === 0 || !result.data) return null;
19
- return result.data;
18
+ if (!result || Object.keys(result).length === 0) return [];
19
+ return result.data ?? [];
20
20
  }
21
21
  }
@@ -23,7 +23,7 @@ import type {
23
23
  } from './types';
24
24
  import { BentoBroadcasts } from '../../sdk/broadcasts';
25
25
  import { BentoStats } from '../../sdk/stats';
26
- import { Subscriber } from '../../sdk/subscribers/types';
26
+ import type { Subscriber } from '../../sdk/subscribers/types';
27
27
 
28
28
  export class BentoAPIV1<S = { [key: string]: unknown }, E extends string = '$custom'> {
29
29
  private readonly _client: BentoClient;
@@ -255,9 +255,13 @@ export class BentoAPIV1<S = { [key: string]: unknown }, E extends string = '$cus
255
255
  * Upserts a subscriber in Bento. If the subscriber exists, their data will be updated.
256
256
  * If they don't exist, they will be created with the provided data.
257
257
  *
258
+ * This method still relies on the batch import queue (which can take 1-5 minutes to
259
+ * finish processing), but it automatically attempts to fetch and return the subscriber
260
+ * record after the import has been queued.
261
+ *
258
262
  * @example
259
263
  * ```typescript
260
- * await analytics.V1.upsertSubscriber({
264
+ * const subscriber = await analytics.V1.upsertSubscriber({
261
265
  * email: 'user@example.com',
262
266
  * fields: {
263
267
  * firstName: 'John',
@@ -269,7 +273,7 @@ export class BentoAPIV1<S = { [key: string]: unknown }, E extends string = '$cus
269
273
  * ```
270
274
  *
271
275
  * @param parameters Object containing subscriber data including email, fields, and tags
272
- * @returns Promise<Subscriber<S>> The created or updated subscriber
276
+ * @returns Promise<Subscriber<S> | null> The created or updated subscriber
273
277
  */
274
278
  public async upsertSubscriber(
275
279
  parameters: Omit<AddSubscriberParameters<S>, 'date'> & {
@@ -278,16 +282,18 @@ export class BentoAPIV1<S = { [key: string]: unknown }, E extends string = '$cus
278
282
  }
279
283
  ): Promise<Subscriber<S> | null> {
280
284
  await this.Batch.importSubscribers({
281
- subscribers: [{
282
- email: parameters.email,
283
- ...parameters.fields,
284
- ...(parameters.tags && { tags: parameters.tags }),
285
- ...(parameters.remove_tags && { remove_tags: parameters.remove_tags })
286
- } as { email: string } & Partial<S>]
285
+ subscribers: [
286
+ {
287
+ email: parameters.email,
288
+ ...parameters.fields,
289
+ ...(parameters.tags && { tags: parameters.tags }),
290
+ ...(parameters.remove_tags && { remove_tags: parameters.remove_tags }),
291
+ } as { email: string } & Partial<S>,
292
+ ],
287
293
  });
288
294
 
289
295
  return this.Subscribers.getSubscribers({
290
- email: parameters.email
296
+ email: parameters.email,
291
297
  });
292
298
  }
293
299
  }