@cstar.help/js 0.17.5 → 0.18.1

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.
@@ -222,6 +222,7 @@ var RealtimeManager = class {
222
222
  // src/chat/presence.ts
223
223
  var TYPING_THROTTLE_MS = 2e3;
224
224
  var TYPING_CLEAR_DELAY_MS = 3e3;
225
+ var PRIVATE_TOPIC = { config: { private: true } };
225
226
  var PresenceManager = class {
226
227
  constructor(options) {
227
228
  this.client = null;
@@ -245,7 +246,7 @@ var PresenceManager = class {
245
246
  };
246
247
  const channelName = `typing:${ticketId}`;
247
248
  this.removeChannel(channelName);
248
- const channel = this.client.channel(`ticket:${ticketId}`);
249
+ const channel = this.client.channel(`ticket:${ticketId}`, PRIVATE_TOPIC);
249
250
  this.channels.set(channelName, channel);
250
251
  channel.on("broadcast", { event: "typing" }, (data) => {
251
252
  if (this.channels.get(channelName) !== channel) return;
@@ -297,7 +298,7 @@ var PresenceManager = class {
297
298
  const sendChannelName = `typing-send:${ticketId}`;
298
299
  let channel = this.channels.get(sendChannelName);
299
300
  if (!channel) {
300
- channel = this.client.channel(`ticket:${ticketId}`);
301
+ channel = this.client.channel(`ticket:${ticketId}`, PRIVATE_TOPIC);
301
302
  channel.subscribe();
302
303
  this.channels.set(sendChannelName, channel);
303
304
  }
@@ -475,6 +476,8 @@ var DEFAULT_POLLING_INTERVAL = 3e3;
475
476
  var ChatClient = class {
476
477
  constructor(config) {
477
478
  this.sessionToken = null;
479
+ this.refreshToken = null;
480
+ this.refreshPromise = null;
478
481
  this.teamId = null;
479
482
  this.customerId = null;
480
483
  this.authMode = null;
@@ -567,6 +570,7 @@ var ChatClient = class {
567
570
  this.applySession(
568
571
  {
569
572
  accessToken: response.accessToken,
573
+ refreshToken: response.refreshToken ?? void 0,
570
574
  teamId,
571
575
  customerName: response.customer.name ?? response.customer.email
572
576
  },
@@ -597,6 +601,7 @@ var ChatClient = class {
597
601
  applySession(params, generation) {
598
602
  this.assertSession(generation);
599
603
  this.sessionToken = params.accessToken;
604
+ this.refreshToken = params.refreshToken ?? null;
600
605
  if (params.teamId) {
601
606
  this.teamId = params.teamId;
602
607
  }
@@ -706,6 +711,8 @@ var ChatClient = class {
706
711
  this.pollingSubscriptions.clear();
707
712
  this.realtimeMessageSubscriptions.clear();
708
713
  this.sessionToken = null;
714
+ this.refreshToken = null;
715
+ this.refreshPromise = null;
709
716
  this.teamId = null;
710
717
  this.customerId = null;
711
718
  this.authMode = null;
@@ -737,7 +744,7 @@ var ChatClient = class {
737
744
  * the first authenticated round-trip, `getAuthMode()` returns the
738
745
  * server-confirmed mode.
739
746
  */
740
- async fetch(path, options) {
747
+ async fetch(path, options, retriedAfterRefresh = false) {
741
748
  const generation = this.sessionGeneration;
742
749
  const assertRequest = () => {
743
750
  this.assertSession(generation);
@@ -748,8 +755,10 @@ var ChatClient = class {
748
755
  const headers = {
749
756
  "Content-Type": "application/json"
750
757
  };
751
- if (this.sessionToken) {
752
- headers["Authorization"] = `Bearer ${this.sessionToken}`;
758
+ if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
759
+ const sentToken = this.sessionToken;
760
+ if (sentToken) {
761
+ headers["Authorization"] = `Bearer ${sentToken}`;
753
762
  }
754
763
  let response;
755
764
  try {
@@ -777,11 +786,17 @@ var ChatClient = class {
777
786
  }
778
787
  assertRequest();
779
788
  const requestId = responseHeaders?.get("X-Request-Id") ?? null;
789
+ if (response.status === 401 && sentToken && this.refreshToken && !retriedAfterRefresh) {
790
+ const refreshed = await this.refreshSession(sentToken);
791
+ assertRequest();
792
+ if (refreshed) return this.fetch(path, options, true);
793
+ }
780
794
  if (!response.ok) {
781
- const errorBody = await response.json().catch(() => ({ error: { code: "UNKNOWN", message: "Request failed" } }));
795
+ const errorBody = await response.json().catch(() => null);
782
796
  assertRequest();
783
- const code = errorBody.error?.code ?? "UNKNOWN";
784
- const message = errorBody.error?.message ?? `Widget API error: ${response.status}`;
797
+ const structured = errorBody?.error && typeof errorBody.error === "object" ? errorBody.error : null;
798
+ const code = structured?.code ?? "UNKNOWN";
799
+ const message = structured?.message ?? (typeof errorBody?.error === "string" ? errorBody.error : null) ?? (typeof errorBody?.message === "string" ? errorBody.message : null) ?? `Widget API error: ${response.status}`;
785
800
  if (response.status === 401 || response.status === 403) {
786
801
  throw new ChatClientAuthError({
787
802
  message,
@@ -822,9 +837,52 @@ var ChatClient = class {
822
837
  return body;
823
838
  } catch (error) {
824
839
  assertRequest();
840
+ if (options.idempotencyKey)
841
+ throw new ChatClientNetworkError({
842
+ message: "Could not read the message acknowledgment",
843
+ originalError: error
844
+ });
825
845
  throw error;
826
846
  }
827
847
  }
848
+ /**
849
+ * Renew the access token with the stored refresh token. Concurrent 401s
850
+ * share one refresh. Resolves true when a newer token is in place.
851
+ */
852
+ refreshSession(rejectedToken) {
853
+ if (this.sessionToken && this.sessionToken !== rejectedToken) return Promise.resolve(true);
854
+ if (!this.refreshPromise) {
855
+ const pending = this.requestRefresh().finally(() => {
856
+ if (this.refreshPromise === pending) this.refreshPromise = null;
857
+ });
858
+ this.refreshPromise = pending;
859
+ }
860
+ return this.refreshPromise;
861
+ }
862
+ async requestRefresh() {
863
+ const generation = this.sessionGeneration;
864
+ const refreshToken = this.refreshToken;
865
+ if (!refreshToken) return false;
866
+ try {
867
+ const response = await globalThis.fetch(`${this.baseUrl}/api/widget/auth/refresh`, {
868
+ method: "POST",
869
+ headers: { "Content-Type": "application/json" },
870
+ body: JSON.stringify({ refreshToken })
871
+ });
872
+ if (generation !== this.sessionGeneration) return false;
873
+ if (!response.ok) {
874
+ if (response.status === 401) this.refreshToken = null;
875
+ return false;
876
+ }
877
+ const body = await response.json();
878
+ if (generation !== this.sessionGeneration || !body.accessToken) return false;
879
+ this.sessionToken = body.accessToken;
880
+ this.refreshToken = body.refreshToken ?? refreshToken;
881
+ return true;
882
+ } catch {
883
+ return false;
884
+ }
885
+ }
828
886
  /**
829
887
  * Look up the team UUID from the configured `teamSlug`. Used by
830
888
  * `loginWithCredentials`. HMAC integrators (`identify()`) never hit
@@ -1297,26 +1355,80 @@ var MessagesApi = class {
1297
1355
  assertCurrent();
1298
1356
  return response.data.messages;
1299
1357
  }
1300
- /** Send a message to a ticket. */
1301
- async send(ticketId, content) {
1358
+ /**
1359
+ * Send one logical reply. Network and server failures retry twice with the
1360
+ * same key. A separate send call is a new intent unless a retained key is supplied.
1361
+ */
1362
+ async send(ticketId, content, options = {}) {
1302
1363
  const assertCurrent = this.chat.captureSession();
1364
+ options.signal?.throwIfAborted();
1365
+ const key = options.idempotencyKey ?? createMessageRequestKey();
1366
+ if (!key || key.length > 250 || key.trim() !== key || /[^\x20-\x7e]/.test(key))
1367
+ throw new ChatClientError({
1368
+ code: "INVALID_IDEMPOTENCY_KEY",
1369
+ message: "Idempotency-Key must contain 1 to 250 printable characters, without surrounding whitespace."
1370
+ });
1303
1371
  const teamId = await this.chat.ensureTeamId();
1304
1372
  assertCurrent();
1305
- const response = await this.chat.fetch(
1306
- "/api/widget/messages",
1307
- {
1308
- method: "POST",
1309
- body: {
1310
- teamId,
1311
- ticketId,
1312
- content
1313
- }
1373
+ for (let attempt = 0; ; attempt++) {
1374
+ assertCurrent();
1375
+ options.signal?.throwIfAborted();
1376
+ try {
1377
+ const response = await this.chat.fetch(
1378
+ "/api/widget/messages",
1379
+ {
1380
+ method: "POST",
1381
+ body: { teamId, ticketId, content },
1382
+ idempotencyKey: key,
1383
+ signal: options.signal
1384
+ }
1385
+ );
1386
+ assertCurrent();
1387
+ if (typeof response?.data?.message?.id !== "string" || !response.data.message.id)
1388
+ throw new ChatClientNetworkError({
1389
+ message: "Message acknowledgment was incomplete",
1390
+ originalError: new Error(
1391
+ "The reply may already be saved. Retry with the same send identity."
1392
+ )
1393
+ });
1394
+ return response.data.message;
1395
+ } catch (error) {
1396
+ assertCurrent();
1397
+ options.signal?.throwIfAborted();
1398
+ if (error instanceof ChatClientError) error.idempotencyKey = key;
1399
+ if (attempt >= 2 || !(error instanceof ChatClientNetworkError || error instanceof ChatClientServerError))
1400
+ throw error;
1401
+ await waitForMessageRetry(250 * 2 ** attempt, options.signal);
1314
1402
  }
1315
- );
1316
- assertCurrent();
1317
- return response.data.message;
1403
+ }
1318
1404
  }
1319
1405
  };
1406
+ function createMessageRequestKey() {
1407
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
1408
+ if (typeof globalThis.crypto?.getRandomValues !== "function")
1409
+ throw new ChatClientError({
1410
+ code: "RANDOM_UNAVAILABLE",
1411
+ message: "Secure random values are required to send a message safely."
1412
+ });
1413
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
1414
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1415
+ }
1416
+ function waitForMessageRetry(delay, signal) {
1417
+ signal?.throwIfAborted();
1418
+ return new Promise((resolve, reject) => {
1419
+ const finish = () => {
1420
+ signal?.removeEventListener("abort", abort);
1421
+ resolve();
1422
+ };
1423
+ const timer = setTimeout(finish, delay);
1424
+ const abort = () => {
1425
+ clearTimeout(timer);
1426
+ signal?.removeEventListener("abort", abort);
1427
+ reject(signal?.reason);
1428
+ };
1429
+ signal?.addEventListener("abort", abort, { once: true });
1430
+ });
1431
+ }
1320
1432
  function decodeJwtSub(jwt) {
1321
1433
  try {
1322
1434
  const parts = jwt.split(".");
@@ -28,6 +28,11 @@ type ChatAuthMode = 'hmac' | 'session';
28
28
  interface LoginWithSessionParams {
29
29
  /** Supabase access token (JWT). Used as Bearer for every subsequent call. */
30
30
  accessToken: string;
31
+ /**
32
+ * Optional: the matching refresh token. When supplied, the client renews
33
+ * an expired access token through `/api/widget/auth/refresh` on a 401.
34
+ */
35
+ refreshToken?: string;
31
36
  /** Optional: the team UUID. If omitted, derived server-side from the token. */
32
37
  teamId?: string;
33
38
  /** Optional: a hint for the customer name (UI only — server stays authoritative). */
@@ -132,6 +137,8 @@ interface TicketListResponse {
132
137
  /** A message on a ticket. */
133
138
  interface Message {
134
139
  id: string;
140
+ /** Opaque send identity, used to reconcile a reply after an uncertain response. */
141
+ client_request_key?: string | null;
135
142
  content: string;
136
143
  sender: 'customer' | 'agent';
137
144
  sender_name: string;
@@ -144,6 +151,12 @@ interface Message {
144
151
  /** Whether this message was sent by an AI agent. */
145
152
  is_bot?: boolean;
146
153
  }
154
+ /** Options for one logical customer reply, including any retries. */
155
+ interface MessageSendOptions {
156
+ /** Retain for a manual retry of the same ticket, content and attachments. Use a new key for each intentional send. Receipts last 24 hours. */
157
+ idempotencyKey?: string;
158
+ signal?: AbortSignal;
159
+ }
147
160
  /** A confirmed customer's message attachment. Never a Storage path or URL. */
148
161
  interface CustomerAttachmentReference {
149
162
  readonly ticketId: string;
@@ -170,6 +183,7 @@ interface TicketListParams {
170
183
  }
171
184
  /** Parameters for creating a new ticket. */
172
185
  interface TicketCreateParams {
186
+ /** Conversation subject. Subjects over 255 characters are shortened with an ellipsis, never rejected. */
173
187
  subject: string;
174
188
  message?: string;
175
189
  }
@@ -231,6 +245,8 @@ declare class ChatClientError extends Error {
231
245
  readonly code: string;
232
246
  readonly statusCode: number;
233
247
  readonly requestId: string | null;
248
+ /** Customer message send key retained for a manual retry after an uncertain outcome. */
249
+ idempotencyKey?: string;
234
250
  constructor(arg: ChatErrorOpts | string);
235
251
  }
236
252
  /** 401 / 403 — auth failed (bad/expired token, wrong team, etc.). */
@@ -316,6 +332,8 @@ declare class ChatClient {
316
332
  private readonly pollingInterval;
317
333
  private readonly realtimeEnabled;
318
334
  private sessionToken;
335
+ private refreshToken;
336
+ private refreshPromise;
319
337
  private teamId;
320
338
  private customerId;
321
339
  private authMode;
@@ -429,7 +447,14 @@ declare class ChatClient {
429
447
  signal?: AbortSignal;
430
448
  cache?: RequestCache;
431
449
  readBody?: (response: Response) => Promise<T>;
432
- }): Promise<T>;
450
+ idempotencyKey?: string;
451
+ }, retriedAfterRefresh?: boolean): Promise<T>;
452
+ /**
453
+ * Renew the access token with the stored refresh token. Concurrent 401s
454
+ * share one refresh. Resolves true when a newer token is in place.
455
+ */
456
+ private refreshSession;
457
+ private requestRefresh;
433
458
  /**
434
459
  * Look up the team UUID from the configured `teamSlug`. Used by
435
460
  * `loginWithCredentials`. HMAC integrators (`identify()`) never hit
@@ -501,8 +526,11 @@ declare class MessagesApi {
501
526
  constructor(chat: ChatClient);
502
527
  /** List messages for a ticket. */
503
528
  list(ticketId: string): Promise<Message[]>;
504
- /** Send a message to a ticket. */
505
- send(ticketId: string, content: string): Promise<Message>;
529
+ /**
530
+ * Send one logical reply. Network and server failures retry twice with the
531
+ * same key. A separate send call is a new intent unless a retained key is supplied.
532
+ */
533
+ send(ticketId: string, content: string, options?: MessageSendOptions): Promise<Message>;
506
534
  }
507
535
 
508
- export { type AttachmentDownloadOptions, type ChatAuthMode, ChatClient, ChatClientAuthError, ChatClientError, ChatClientNetworkError, ChatClientRateLimitError, ChatClientServerError, type ChatConfig, type ChatStateListener, type CustomerAttachmentReference, type DownloadedAttachment, type IdentifyParams, type IdentifyResult, type LoginWithCredentialsParams, type LoginWithCredentialsResult, type LoginWithSessionParams, type Message, type MessageCallback, type RealtimeTokenResponse, type Ticket, type TicketCreateParams, type TicketListParams, type TicketListResponse, type TypingCallback, type TypingPayload };
536
+ export { type AttachmentDownloadOptions, type ChatAuthMode, ChatClient, ChatClientAuthError, ChatClientError, ChatClientNetworkError, ChatClientRateLimitError, ChatClientServerError, type ChatConfig, type ChatStateListener, type CustomerAttachmentReference, type DownloadedAttachment, type IdentifyParams, type IdentifyResult, type LoginWithCredentialsParams, type LoginWithCredentialsResult, type LoginWithSessionParams, type Message, type MessageCallback, type MessageSendOptions, type RealtimeTokenResponse, type Ticket, type TicketCreateParams, type TicketListParams, type TicketListResponse, type TypingCallback, type TypingPayload };
@@ -28,6 +28,11 @@ type ChatAuthMode = 'hmac' | 'session';
28
28
  interface LoginWithSessionParams {
29
29
  /** Supabase access token (JWT). Used as Bearer for every subsequent call. */
30
30
  accessToken: string;
31
+ /**
32
+ * Optional: the matching refresh token. When supplied, the client renews
33
+ * an expired access token through `/api/widget/auth/refresh` on a 401.
34
+ */
35
+ refreshToken?: string;
31
36
  /** Optional: the team UUID. If omitted, derived server-side from the token. */
32
37
  teamId?: string;
33
38
  /** Optional: a hint for the customer name (UI only — server stays authoritative). */
@@ -132,6 +137,8 @@ interface TicketListResponse {
132
137
  /** A message on a ticket. */
133
138
  interface Message {
134
139
  id: string;
140
+ /** Opaque send identity, used to reconcile a reply after an uncertain response. */
141
+ client_request_key?: string | null;
135
142
  content: string;
136
143
  sender: 'customer' | 'agent';
137
144
  sender_name: string;
@@ -144,6 +151,12 @@ interface Message {
144
151
  /** Whether this message was sent by an AI agent. */
145
152
  is_bot?: boolean;
146
153
  }
154
+ /** Options for one logical customer reply, including any retries. */
155
+ interface MessageSendOptions {
156
+ /** Retain for a manual retry of the same ticket, content and attachments. Use a new key for each intentional send. Receipts last 24 hours. */
157
+ idempotencyKey?: string;
158
+ signal?: AbortSignal;
159
+ }
147
160
  /** A confirmed customer's message attachment. Never a Storage path or URL. */
148
161
  interface CustomerAttachmentReference {
149
162
  readonly ticketId: string;
@@ -170,6 +183,7 @@ interface TicketListParams {
170
183
  }
171
184
  /** Parameters for creating a new ticket. */
172
185
  interface TicketCreateParams {
186
+ /** Conversation subject. Subjects over 255 characters are shortened with an ellipsis, never rejected. */
173
187
  subject: string;
174
188
  message?: string;
175
189
  }
@@ -231,6 +245,8 @@ declare class ChatClientError extends Error {
231
245
  readonly code: string;
232
246
  readonly statusCode: number;
233
247
  readonly requestId: string | null;
248
+ /** Customer message send key retained for a manual retry after an uncertain outcome. */
249
+ idempotencyKey?: string;
234
250
  constructor(arg: ChatErrorOpts | string);
235
251
  }
236
252
  /** 401 / 403 — auth failed (bad/expired token, wrong team, etc.). */
@@ -316,6 +332,8 @@ declare class ChatClient {
316
332
  private readonly pollingInterval;
317
333
  private readonly realtimeEnabled;
318
334
  private sessionToken;
335
+ private refreshToken;
336
+ private refreshPromise;
319
337
  private teamId;
320
338
  private customerId;
321
339
  private authMode;
@@ -429,7 +447,14 @@ declare class ChatClient {
429
447
  signal?: AbortSignal;
430
448
  cache?: RequestCache;
431
449
  readBody?: (response: Response) => Promise<T>;
432
- }): Promise<T>;
450
+ idempotencyKey?: string;
451
+ }, retriedAfterRefresh?: boolean): Promise<T>;
452
+ /**
453
+ * Renew the access token with the stored refresh token. Concurrent 401s
454
+ * share one refresh. Resolves true when a newer token is in place.
455
+ */
456
+ private refreshSession;
457
+ private requestRefresh;
433
458
  /**
434
459
  * Look up the team UUID from the configured `teamSlug`. Used by
435
460
  * `loginWithCredentials`. HMAC integrators (`identify()`) never hit
@@ -501,8 +526,11 @@ declare class MessagesApi {
501
526
  constructor(chat: ChatClient);
502
527
  /** List messages for a ticket. */
503
528
  list(ticketId: string): Promise<Message[]>;
504
- /** Send a message to a ticket. */
505
- send(ticketId: string, content: string): Promise<Message>;
529
+ /**
530
+ * Send one logical reply. Network and server failures retry twice with the
531
+ * same key. A separate send call is a new intent unless a retained key is supplied.
532
+ */
533
+ send(ticketId: string, content: string, options?: MessageSendOptions): Promise<Message>;
506
534
  }
507
535
 
508
- export { type AttachmentDownloadOptions, type ChatAuthMode, ChatClient, ChatClientAuthError, ChatClientError, ChatClientNetworkError, ChatClientRateLimitError, ChatClientServerError, type ChatConfig, type ChatStateListener, type CustomerAttachmentReference, type DownloadedAttachment, type IdentifyParams, type IdentifyResult, type LoginWithCredentialsParams, type LoginWithCredentialsResult, type LoginWithSessionParams, type Message, type MessageCallback, type RealtimeTokenResponse, type Ticket, type TicketCreateParams, type TicketListParams, type TicketListResponse, type TypingCallback, type TypingPayload };
536
+ export { type AttachmentDownloadOptions, type ChatAuthMode, ChatClient, ChatClientAuthError, ChatClientError, ChatClientNetworkError, ChatClientRateLimitError, ChatClientServerError, type ChatConfig, type ChatStateListener, type CustomerAttachmentReference, type DownloadedAttachment, type IdentifyParams, type IdentifyResult, type LoginWithCredentialsParams, type LoginWithCredentialsResult, type LoginWithSessionParams, type Message, type MessageCallback, type MessageSendOptions, type RealtimeTokenResponse, type Ticket, type TicketCreateParams, type TicketListParams, type TicketListResponse, type TypingCallback, type TypingPayload };
@@ -181,6 +181,7 @@ var RealtimeManager = class {
181
181
  // src/chat/presence.ts
182
182
  var TYPING_THROTTLE_MS = 2e3;
183
183
  var TYPING_CLEAR_DELAY_MS = 3e3;
184
+ var PRIVATE_TOPIC = { config: { private: true } };
184
185
  var PresenceManager = class {
185
186
  constructor(options) {
186
187
  this.client = null;
@@ -204,7 +205,7 @@ var PresenceManager = class {
204
205
  };
205
206
  const channelName = `typing:${ticketId}`;
206
207
  this.removeChannel(channelName);
207
- const channel = this.client.channel(`ticket:${ticketId}`);
208
+ const channel = this.client.channel(`ticket:${ticketId}`, PRIVATE_TOPIC);
208
209
  this.channels.set(channelName, channel);
209
210
  channel.on("broadcast", { event: "typing" }, (data) => {
210
211
  if (this.channels.get(channelName) !== channel) return;
@@ -256,7 +257,7 @@ var PresenceManager = class {
256
257
  const sendChannelName = `typing-send:${ticketId}`;
257
258
  let channel = this.channels.get(sendChannelName);
258
259
  if (!channel) {
259
- channel = this.client.channel(`ticket:${ticketId}`);
260
+ channel = this.client.channel(`ticket:${ticketId}`, PRIVATE_TOPIC);
260
261
  channel.subscribe();
261
262
  this.channels.set(sendChannelName, channel);
262
263
  }
@@ -434,6 +435,8 @@ var DEFAULT_POLLING_INTERVAL = 3e3;
434
435
  var ChatClient = class {
435
436
  constructor(config) {
436
437
  this.sessionToken = null;
438
+ this.refreshToken = null;
439
+ this.refreshPromise = null;
437
440
  this.teamId = null;
438
441
  this.customerId = null;
439
442
  this.authMode = null;
@@ -526,6 +529,7 @@ var ChatClient = class {
526
529
  this.applySession(
527
530
  {
528
531
  accessToken: response.accessToken,
532
+ refreshToken: response.refreshToken ?? void 0,
529
533
  teamId,
530
534
  customerName: response.customer.name ?? response.customer.email
531
535
  },
@@ -556,6 +560,7 @@ var ChatClient = class {
556
560
  applySession(params, generation) {
557
561
  this.assertSession(generation);
558
562
  this.sessionToken = params.accessToken;
563
+ this.refreshToken = params.refreshToken ?? null;
559
564
  if (params.teamId) {
560
565
  this.teamId = params.teamId;
561
566
  }
@@ -665,6 +670,8 @@ var ChatClient = class {
665
670
  this.pollingSubscriptions.clear();
666
671
  this.realtimeMessageSubscriptions.clear();
667
672
  this.sessionToken = null;
673
+ this.refreshToken = null;
674
+ this.refreshPromise = null;
668
675
  this.teamId = null;
669
676
  this.customerId = null;
670
677
  this.authMode = null;
@@ -696,7 +703,7 @@ var ChatClient = class {
696
703
  * the first authenticated round-trip, `getAuthMode()` returns the
697
704
  * server-confirmed mode.
698
705
  */
699
- async fetch(path, options) {
706
+ async fetch(path, options, retriedAfterRefresh = false) {
700
707
  const generation = this.sessionGeneration;
701
708
  const assertRequest = () => {
702
709
  this.assertSession(generation);
@@ -707,8 +714,10 @@ var ChatClient = class {
707
714
  const headers = {
708
715
  "Content-Type": "application/json"
709
716
  };
710
- if (this.sessionToken) {
711
- headers["Authorization"] = `Bearer ${this.sessionToken}`;
717
+ if (options.idempotencyKey) headers["Idempotency-Key"] = options.idempotencyKey;
718
+ const sentToken = this.sessionToken;
719
+ if (sentToken) {
720
+ headers["Authorization"] = `Bearer ${sentToken}`;
712
721
  }
713
722
  let response;
714
723
  try {
@@ -736,11 +745,17 @@ var ChatClient = class {
736
745
  }
737
746
  assertRequest();
738
747
  const requestId = responseHeaders?.get("X-Request-Id") ?? null;
748
+ if (response.status === 401 && sentToken && this.refreshToken && !retriedAfterRefresh) {
749
+ const refreshed = await this.refreshSession(sentToken);
750
+ assertRequest();
751
+ if (refreshed) return this.fetch(path, options, true);
752
+ }
739
753
  if (!response.ok) {
740
- const errorBody = await response.json().catch(() => ({ error: { code: "UNKNOWN", message: "Request failed" } }));
754
+ const errorBody = await response.json().catch(() => null);
741
755
  assertRequest();
742
- const code = errorBody.error?.code ?? "UNKNOWN";
743
- const message = errorBody.error?.message ?? `Widget API error: ${response.status}`;
756
+ const structured = errorBody?.error && typeof errorBody.error === "object" ? errorBody.error : null;
757
+ const code = structured?.code ?? "UNKNOWN";
758
+ const message = structured?.message ?? (typeof errorBody?.error === "string" ? errorBody.error : null) ?? (typeof errorBody?.message === "string" ? errorBody.message : null) ?? `Widget API error: ${response.status}`;
744
759
  if (response.status === 401 || response.status === 403) {
745
760
  throw new ChatClientAuthError({
746
761
  message,
@@ -781,9 +796,52 @@ var ChatClient = class {
781
796
  return body;
782
797
  } catch (error) {
783
798
  assertRequest();
799
+ if (options.idempotencyKey)
800
+ throw new ChatClientNetworkError({
801
+ message: "Could not read the message acknowledgment",
802
+ originalError: error
803
+ });
784
804
  throw error;
785
805
  }
786
806
  }
807
+ /**
808
+ * Renew the access token with the stored refresh token. Concurrent 401s
809
+ * share one refresh. Resolves true when a newer token is in place.
810
+ */
811
+ refreshSession(rejectedToken) {
812
+ if (this.sessionToken && this.sessionToken !== rejectedToken) return Promise.resolve(true);
813
+ if (!this.refreshPromise) {
814
+ const pending = this.requestRefresh().finally(() => {
815
+ if (this.refreshPromise === pending) this.refreshPromise = null;
816
+ });
817
+ this.refreshPromise = pending;
818
+ }
819
+ return this.refreshPromise;
820
+ }
821
+ async requestRefresh() {
822
+ const generation = this.sessionGeneration;
823
+ const refreshToken = this.refreshToken;
824
+ if (!refreshToken) return false;
825
+ try {
826
+ const response = await globalThis.fetch(`${this.baseUrl}/api/widget/auth/refresh`, {
827
+ method: "POST",
828
+ headers: { "Content-Type": "application/json" },
829
+ body: JSON.stringify({ refreshToken })
830
+ });
831
+ if (generation !== this.sessionGeneration) return false;
832
+ if (!response.ok) {
833
+ if (response.status === 401) this.refreshToken = null;
834
+ return false;
835
+ }
836
+ const body = await response.json();
837
+ if (generation !== this.sessionGeneration || !body.accessToken) return false;
838
+ this.sessionToken = body.accessToken;
839
+ this.refreshToken = body.refreshToken ?? refreshToken;
840
+ return true;
841
+ } catch {
842
+ return false;
843
+ }
844
+ }
787
845
  /**
788
846
  * Look up the team UUID from the configured `teamSlug`. Used by
789
847
  * `loginWithCredentials`. HMAC integrators (`identify()`) never hit
@@ -1256,26 +1314,80 @@ var MessagesApi = class {
1256
1314
  assertCurrent();
1257
1315
  return response.data.messages;
1258
1316
  }
1259
- /** Send a message to a ticket. */
1260
- async send(ticketId, content) {
1317
+ /**
1318
+ * Send one logical reply. Network and server failures retry twice with the
1319
+ * same key. A separate send call is a new intent unless a retained key is supplied.
1320
+ */
1321
+ async send(ticketId, content, options = {}) {
1261
1322
  const assertCurrent = this.chat.captureSession();
1323
+ options.signal?.throwIfAborted();
1324
+ const key = options.idempotencyKey ?? createMessageRequestKey();
1325
+ if (!key || key.length > 250 || key.trim() !== key || /[^\x20-\x7e]/.test(key))
1326
+ throw new ChatClientError({
1327
+ code: "INVALID_IDEMPOTENCY_KEY",
1328
+ message: "Idempotency-Key must contain 1 to 250 printable characters, without surrounding whitespace."
1329
+ });
1262
1330
  const teamId = await this.chat.ensureTeamId();
1263
1331
  assertCurrent();
1264
- const response = await this.chat.fetch(
1265
- "/api/widget/messages",
1266
- {
1267
- method: "POST",
1268
- body: {
1269
- teamId,
1270
- ticketId,
1271
- content
1272
- }
1332
+ for (let attempt = 0; ; attempt++) {
1333
+ assertCurrent();
1334
+ options.signal?.throwIfAborted();
1335
+ try {
1336
+ const response = await this.chat.fetch(
1337
+ "/api/widget/messages",
1338
+ {
1339
+ method: "POST",
1340
+ body: { teamId, ticketId, content },
1341
+ idempotencyKey: key,
1342
+ signal: options.signal
1343
+ }
1344
+ );
1345
+ assertCurrent();
1346
+ if (typeof response?.data?.message?.id !== "string" || !response.data.message.id)
1347
+ throw new ChatClientNetworkError({
1348
+ message: "Message acknowledgment was incomplete",
1349
+ originalError: new Error(
1350
+ "The reply may already be saved. Retry with the same send identity."
1351
+ )
1352
+ });
1353
+ return response.data.message;
1354
+ } catch (error) {
1355
+ assertCurrent();
1356
+ options.signal?.throwIfAborted();
1357
+ if (error instanceof ChatClientError) error.idempotencyKey = key;
1358
+ if (attempt >= 2 || !(error instanceof ChatClientNetworkError || error instanceof ChatClientServerError))
1359
+ throw error;
1360
+ await waitForMessageRetry(250 * 2 ** attempt, options.signal);
1273
1361
  }
1274
- );
1275
- assertCurrent();
1276
- return response.data.message;
1362
+ }
1277
1363
  }
1278
1364
  };
1365
+ function createMessageRequestKey() {
1366
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
1367
+ if (typeof globalThis.crypto?.getRandomValues !== "function")
1368
+ throw new ChatClientError({
1369
+ code: "RANDOM_UNAVAILABLE",
1370
+ message: "Secure random values are required to send a message safely."
1371
+ });
1372
+ const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
1373
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
1374
+ }
1375
+ function waitForMessageRetry(delay, signal) {
1376
+ signal?.throwIfAborted();
1377
+ return new Promise((resolve, reject) => {
1378
+ const finish = () => {
1379
+ signal?.removeEventListener("abort", abort);
1380
+ resolve();
1381
+ };
1382
+ const timer = setTimeout(finish, delay);
1383
+ const abort = () => {
1384
+ clearTimeout(timer);
1385
+ signal?.removeEventListener("abort", abort);
1386
+ reject(signal?.reason);
1387
+ };
1388
+ signal?.addEventListener("abort", abort, { once: true });
1389
+ });
1390
+ }
1279
1391
  function decodeJwtSub(jwt) {
1280
1392
  try {
1281
1393
  const parts = jwt.split(".");