@cstar.help/js 0.16.1 → 0.17.2

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.
@@ -40,6 +40,28 @@ __export(chat_exports, {
40
40
  module.exports = __toCommonJS(chat_exports);
41
41
 
42
42
  // src/chat/realtime.ts
43
+ var channelRemovals = /* @__PURE__ */ new WeakMap();
44
+ var CHANNEL_DRAIN_TIMEOUT_MS = 5e3;
45
+ function removeRealtimeChannel(client, channel) {
46
+ const pending = channelRemovals.get(client) ?? /* @__PURE__ */ new Set();
47
+ channelRemovals.set(client, pending);
48
+ const removal = client.removeChannel(channel);
49
+ pending.add(removal);
50
+ const settled = () => pending.delete(removal);
51
+ void removal.then(settled, settled);
52
+ }
53
+ async function closeRealtimeClient(client) {
54
+ const pending = [...channelRemovals.get(client) ?? []];
55
+ if (pending.length) {
56
+ let timeout;
57
+ const deadline = new Promise((resolve) => {
58
+ timeout = setTimeout(resolve, CHANNEL_DRAIN_TIMEOUT_MS);
59
+ });
60
+ await Promise.race([Promise.allSettled(pending), deadline]);
61
+ clearTimeout(timeout);
62
+ }
63
+ await client.realtime.disconnect();
64
+ }
43
65
  var RealtimeManager = class {
44
66
  constructor(options) {
45
67
  this.client = null;
@@ -47,6 +69,8 @@ var RealtimeManager = class {
47
69
  this.channels = /* @__PURE__ */ new Map();
48
70
  this.refreshTimer = null;
49
71
  this.currentToken = null;
72
+ this.destroyed = false;
73
+ this.cleanupPromise = null;
50
74
  this.options = options;
51
75
  }
52
76
  /**
@@ -54,6 +78,7 @@ var RealtimeManager = class {
54
78
  * Returns true if successful, false if the realtime runtime is unavailable.
55
79
  */
56
80
  init() {
81
+ if (this.destroyed) return Promise.resolve(false);
57
82
  if (this.initPromise) return this.initPromise;
58
83
  this.initPromise = this.doInit();
59
84
  return this.initPromise;
@@ -61,11 +86,17 @@ var RealtimeManager = class {
61
86
  async doInit() {
62
87
  try {
63
88
  const tokenData = await this.options.fetchToken();
89
+ if (this.destroyed) return false;
64
90
  const { createClient } = await import(
65
91
  /* webpackIgnore: true */
66
92
  "@supabase/supabase-js"
67
93
  );
94
+ if (this.destroyed) return false;
95
+ this.currentToken = tokenData.token;
68
96
  this.client = createClient(tokenData.realtimeUrl, tokenData.realtimeKey, {
97
+ // Reconnects and heartbeats must refresh from cStar's customer JWT,
98
+ // rather than the dedicated client's empty hosted-auth session.
99
+ accessToken: async () => this.currentToken,
69
100
  auth: { persistSession: false, autoRefreshToken: false },
70
101
  realtime: { params: { eventsPerSecond: 10 } }
71
102
  });
@@ -92,10 +123,13 @@ var RealtimeManager = class {
92
123
  this.refreshTimer = setTimeout(() => this.doRefresh(), delay);
93
124
  }
94
125
  async doRefresh() {
126
+ if (this.destroyed) return;
95
127
  try {
96
128
  const tokenData = await this.options.fetchToken();
129
+ if (this.destroyed) return;
97
130
  this.setToken(tokenData);
98
131
  } catch {
132
+ if (this.destroyed) return;
99
133
  this.refreshTimer = setTimeout(() => this.doRefresh(), 3e4);
100
134
  }
101
135
  }
@@ -103,12 +137,14 @@ var RealtimeManager = class {
103
137
  * Subscribe to new messages on a ticket via Postgres Changes.
104
138
  * Returns an unsubscribe function.
105
139
  */
106
- subscribeToMessages(ticketId, callback) {
140
+ subscribeToMessages(ticketId, callback, onStatus) {
107
141
  if (!this.client) return () => {
108
142
  };
109
143
  const channelName = `messages:${ticketId}`;
110
144
  this.removeChannel(channelName);
111
- const channel = this.client.channel(channelName).on(
145
+ const channel = this.client.channel(channelName);
146
+ this.channels.set(channelName, channel);
147
+ channel.on(
112
148
  "postgres_changes",
113
149
  {
114
150
  event: "INSERT",
@@ -117,6 +153,7 @@ var RealtimeManager = class {
117
153
  filter: `ticket_id=eq.${ticketId}`
118
154
  },
119
155
  (payload) => {
156
+ if (this.channels.get(channelName) !== channel) return;
120
157
  const row = payload.new;
121
158
  if (!row) return;
122
159
  const message = {
@@ -131,9 +168,10 @@ var RealtimeManager = class {
131
168
  };
132
169
  callback(message);
133
170
  }
134
- ).subscribe();
135
- this.channels.set(channelName, channel);
136
- return () => this.removeChannel(channelName);
171
+ ).subscribe((status) => {
172
+ if (this.channels.get(channelName) === channel) onStatus?.(status);
173
+ });
174
+ return () => this.removeChannel(channelName, channel);
137
175
  }
138
176
  /** Get the underlying realtime client (for sharing with PresenceManager). */
139
177
  getClient() {
@@ -151,15 +189,18 @@ var RealtimeManager = class {
151
189
  get isReady() {
152
190
  return this.client !== null && this.currentToken !== null;
153
191
  }
154
- removeChannel(name) {
192
+ removeChannel(name, expected) {
155
193
  const existing = this.channels.get(name);
194
+ if (expected && existing !== expected) return;
156
195
  if (existing && this.client) {
157
- this.client.removeChannel(existing);
158
196
  this.channels.delete(name);
197
+ removeRealtimeChannel(this.client, existing);
159
198
  }
160
199
  }
161
200
  /** Clean up all channels, timers, and the client. */
162
201
  destroy() {
202
+ if (this.destroyed) return this.cleanupPromise ?? Promise.resolve();
203
+ this.destroyed = true;
163
204
  if (this.refreshTimer) {
164
205
  clearTimeout(this.refreshTimer);
165
206
  this.refreshTimer = null;
@@ -168,9 +209,13 @@ var RealtimeManager = class {
168
209
  this.removeChannel(name);
169
210
  }
170
211
  this.channels.clear();
212
+ const client = this.client;
171
213
  this.client = null;
172
214
  this.initPromise = null;
173
215
  this.currentToken = null;
216
+ this.cleanupPromise = client ? closeRealtimeClient(client).catch(() => {
217
+ }) : Promise.resolve();
218
+ return this.cleanupPromise;
174
219
  }
175
220
  };
176
221
 
@@ -200,7 +245,10 @@ var PresenceManager = class {
200
245
  };
201
246
  const channelName = `typing:${ticketId}`;
202
247
  this.removeChannel(channelName);
203
- const channel = this.client.channel(`ticket:${ticketId}`).on("broadcast", { event: "typing" }, (data) => {
248
+ const channel = this.client.channel(`ticket:${ticketId}`);
249
+ this.channels.set(channelName, channel);
250
+ channel.on("broadcast", { event: "typing" }, (data) => {
251
+ if (this.channels.get(channelName) !== channel) return;
204
252
  const payload = data?.payload;
205
253
  if (!payload) return;
206
254
  if (payload.agent_id) {
@@ -212,8 +260,7 @@ var PresenceManager = class {
212
260
  callback(typingPayload);
213
261
  }
214
262
  }).subscribe();
215
- this.channels.set(channelName, channel);
216
- return () => this.removeChannel(channelName);
263
+ return () => this.removeChannel(channelName, channel);
217
264
  }
218
265
  /**
219
266
  * Send a customer typing indicator.
@@ -266,11 +313,12 @@ var PresenceManager = class {
266
313
  } catch {
267
314
  }
268
315
  }
269
- removeChannel(name) {
316
+ removeChannel(name, expected) {
270
317
  const existing = this.channels.get(name);
318
+ if (expected && existing !== expected) return;
271
319
  if (existing && this.client) {
272
- this.client.removeChannel(existing);
273
320
  this.channels.delete(name);
321
+ removeRealtimeChannel(this.client, existing);
274
322
  }
275
323
  }
276
324
  /** Clean up all channels and timers. */
@@ -333,6 +381,94 @@ var ChatClientNetworkError = class extends ChatClientError {
333
381
  }
334
382
  };
335
383
 
384
+ // src/chat/attachments.ts
385
+ var UUID = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
386
+ function downloadFilename(header) {
387
+ const extended = header?.match(/(?:^|;)\s*filename\*\s*=\s*UTF-8''([^;]*)/i);
388
+ let name = "";
389
+ if (extended) {
390
+ try {
391
+ name = decodeURIComponent(extended[1]);
392
+ } catch {
393
+ }
394
+ }
395
+ if (!name) {
396
+ const fallback = header?.match(/(?:^|;)\s*filename\s*=\s*(?:"((?:\\.|[^"\\])*)"|([^;]*))/i);
397
+ name = fallback?.[1]?.replace(/\\(.)/g, "$1") ?? fallback?.[2] ?? "";
398
+ }
399
+ return name.split(/[\\/]/).pop()?.replace(/[\x00-\x1f\x7f]/g, "").replace(/^[\s.]+|[\s.]+$/g, "") || "download";
400
+ }
401
+ var AttachmentsApi = class {
402
+ constructor(chat) {
403
+ this.chat = chat;
404
+ }
405
+ async download(reference, options = {}) {
406
+ const assertCurrent = this.chat.captureSession();
407
+ if (!this.chat.getAuthMode())
408
+ throw new ChatClientAuthError("Sign in before downloading a file.");
409
+ const { ticketId, messageId, attachmentIndex } = reference;
410
+ if (typeof ticketId !== "string" || typeof messageId !== "string" || !UUID.test(ticketId) || !UUID.test(messageId) || !Number.isSafeInteger(attachmentIndex) || attachmentIndex < 0) {
411
+ throw new TypeError("A saved ticket, message and nonnegative attachment index are required.");
412
+ }
413
+ const controller = new AbortController();
414
+ const signal = options.signal;
415
+ const download = options.download === false ? "0" : "1";
416
+ const abort = () => controller.abort();
417
+ const assertOwned = () => {
418
+ assertCurrent();
419
+ controller.signal.throwIfAborted();
420
+ };
421
+ const unsubscribe = this.chat.subscribe(() => {
422
+ try {
423
+ assertCurrent();
424
+ } catch {
425
+ abort();
426
+ }
427
+ });
428
+ signal?.addEventListener("abort", abort, { once: true });
429
+ if (signal?.aborted) abort();
430
+ try {
431
+ assertOwned();
432
+ const teamId = await this.chat.ensureTeamId(controller.signal);
433
+ assertOwned();
434
+ const result = await this.chat.fetch(
435
+ "/api/widget/attachments/download",
436
+ {
437
+ method: "GET",
438
+ query: {
439
+ teamId,
440
+ ticketId,
441
+ messageId,
442
+ attachmentIndex: String(attachmentIndex),
443
+ download
444
+ },
445
+ signal: controller.signal,
446
+ cache: "no-store",
447
+ readBody: async (response) => {
448
+ assertOwned();
449
+ const blob = await response.blob();
450
+ assertOwned();
451
+ return {
452
+ blob,
453
+ filename: downloadFilename(response.headers.get("Content-Disposition")),
454
+ contentType: blob.type.split(";")[0] || "application/octet-stream",
455
+ size: blob.size
456
+ };
457
+ }
458
+ }
459
+ );
460
+ assertOwned();
461
+ return result;
462
+ } catch (error) {
463
+ assertOwned();
464
+ throw error;
465
+ } finally {
466
+ unsubscribe();
467
+ signal?.removeEventListener("abort", abort);
468
+ }
469
+ }
470
+ };
471
+
336
472
  // src/chat/index.ts
337
473
  var DEFAULT_BASE_URL = "https://www.cstar.help";
338
474
  var DEFAULT_POLLING_INTERVAL = 3e3;
@@ -342,7 +478,9 @@ var ChatClient = class {
342
478
  this.teamId = null;
343
479
  this.customerId = null;
344
480
  this.authMode = null;
345
- this.pollingTimers = /* @__PURE__ */ new Map();
481
+ this.sessionGeneration = 0;
482
+ this.pollingSubscriptions = /* @__PURE__ */ new Set();
483
+ this.realtimeMessageSubscriptions = /* @__PURE__ */ new Map();
346
484
  this.stateListeners = /* @__PURE__ */ new Set();
347
485
  this.realtimeManager = null;
348
486
  this.presenceManager = null;
@@ -354,6 +492,7 @@ var ChatClient = class {
354
492
  this.realtimeEnabled = config.realtime !== false;
355
493
  this.tickets = new TicketsApi(this);
356
494
  this.messages = new MessagesApi(this);
495
+ this.attachments = new AttachmentsApi(this);
357
496
  this.community = new ChatCommunityApi(this);
358
497
  }
359
498
  /**
@@ -368,6 +507,8 @@ var ChatClient = class {
368
507
  * @param testMode - Whether to use test mode (relaxed timestamp validation)
369
508
  */
370
509
  async identify(customer, signature, testMode = false) {
510
+ const generation = this.retireSession();
511
+ this.assertSession(generation);
371
512
  const response = await this.fetch(
372
513
  "/api/widget/verify-identity",
373
514
  {
@@ -380,15 +521,18 @@ var ChatClient = class {
380
521
  }
381
522
  }
382
523
  );
524
+ this.assertSession(generation);
383
525
  this.sessionToken = response.data.sessionToken;
384
526
  this.teamId = response.data.teamId;
385
527
  this.customerId = response.data.customer.id;
386
528
  this.authMode = "hmac";
387
529
  this.notifyStateChanged();
530
+ this.assertSession(generation);
388
531
  if (this.realtimeEnabled) {
389
532
  this.realtimeInitPromise = this.initRealtime(
390
533
  response.data.customer.id,
391
- response.data.customer.name || customer.name || customer.email
534
+ response.data.customer.name || customer.name || customer.email,
535
+ generation
392
536
  );
393
537
  }
394
538
  return response.data;
@@ -403,11 +547,15 @@ var ChatClient = class {
403
547
  * Supabase session, use `loginWithSession({ accessToken })` instead.
404
548
  */
405
549
  async loginWithCredentials(params) {
550
+ const generation = this.retireSession();
551
+ this.assertSession(generation);
406
552
  const teamId = await this.resolveTeamIdFromSlug();
553
+ this.assertSession(generation);
407
554
  const response = await this.fetch("/api/widget/auth/login", {
408
555
  method: "POST",
409
556
  body: { email: params.email, password: params.password, teamId }
410
557
  });
558
+ this.assertSession(generation);
411
559
  if (!response.accessToken) {
412
560
  throw new ChatClientAuthError({
413
561
  message: "Login succeeded but no session was issued",
@@ -416,11 +564,14 @@ var ChatClient = class {
416
564
  requestId: null
417
565
  });
418
566
  }
419
- await this.loginWithSession({
420
- accessToken: response.accessToken,
421
- teamId,
422
- customerName: response.customer.name ?? response.customer.email
423
- });
567
+ this.applySession(
568
+ {
569
+ accessToken: response.accessToken,
570
+ teamId,
571
+ customerName: response.customer.name ?? response.customer.email
572
+ },
573
+ generation
574
+ );
424
575
  return {
425
576
  customer: {
426
577
  id: this.customerId ?? response.customer.id,
@@ -439,15 +590,22 @@ var ChatClient = class {
439
590
  * every request — there is no client-side validation here.
440
591
  */
441
592
  async loginWithSession(params) {
593
+ const teamId = params.teamId ?? this.teamId;
594
+ const generation = this.retireSession();
595
+ this.applySession({ ...params, ...teamId ? { teamId } : {} }, generation);
596
+ }
597
+ applySession(params, generation) {
598
+ this.assertSession(generation);
442
599
  this.sessionToken = params.accessToken;
443
600
  if (params.teamId) {
444
601
  this.teamId = params.teamId;
445
602
  }
446
603
  this.authMode = "session";
447
604
  this.notifyStateChanged();
605
+ this.assertSession(generation);
448
606
  if (this.realtimeEnabled) {
449
607
  const customerName = params.customerName ?? "";
450
- this.realtimeInitPromise = this.initRealtimeFromSession(customerName);
608
+ this.realtimeInitPromise = this.initRealtimeFromSession(customerName, generation);
451
609
  }
452
610
  }
453
611
  /**
@@ -489,7 +647,15 @@ var ChatClient = class {
489
647
  */
490
648
  onMessage(ticketId, callback) {
491
649
  if (this.realtimeReady && this.realtimeManager) {
492
- return this.realtimeManager.subscribeToMessages(ticketId, callback);
650
+ this.realtimeMessageSubscriptions.get(ticketId)?.();
651
+ const unsubscribe = this.pollMessages(ticketId, callback, this.realtimeManager);
652
+ this.realtimeMessageSubscriptions.set(ticketId, unsubscribe);
653
+ return () => {
654
+ unsubscribe();
655
+ if (this.realtimeMessageSubscriptions.get(ticketId) === unsubscribe) {
656
+ this.realtimeMessageSubscriptions.delete(ticketId);
657
+ }
658
+ };
493
659
  }
494
660
  return this.pollMessages(ticketId, callback);
495
661
  }
@@ -500,8 +666,11 @@ var ChatClient = class {
500
666
  * @returns Unsubscribe function
501
667
  */
502
668
  onTyping(ticketId, callback) {
669
+ const generation = this.sessionGeneration;
503
670
  if (this.realtimeReady && this.presenceManager) {
504
- return this.presenceManager.subscribeToTyping(ticketId, callback);
671
+ return this.presenceManager.subscribeToTyping(ticketId, (typing) => {
672
+ if (generation === this.sessionGeneration) callback(typing);
673
+ });
505
674
  }
506
675
  return () => {
507
676
  };
@@ -517,25 +686,44 @@ var ChatClient = class {
517
686
  }
518
687
  /** Disconnect all subscriptions and clean up. */
519
688
  disconnect() {
520
- if (this.realtimeManager) {
521
- this.realtimeManager.destroy();
522
- this.realtimeManager = null;
523
- }
689
+ this.retireSession();
690
+ }
691
+ retireSession() {
692
+ const generation = ++this.sessionGeneration;
524
693
  if (this.presenceManager) {
525
694
  this.presenceManager.destroy();
526
695
  this.presenceManager = null;
527
696
  }
697
+ if (this.realtimeManager) {
698
+ this.realtimeManager.destroy();
699
+ this.realtimeManager = null;
700
+ }
528
701
  this.realtimeReady = false;
529
702
  this.realtimeInitPromise = null;
530
- for (const timer of this.pollingTimers.values()) {
531
- clearInterval(timer);
703
+ for (const unsubscribe of this.pollingSubscriptions) {
704
+ unsubscribe();
532
705
  }
533
- this.pollingTimers.clear();
706
+ this.pollingSubscriptions.clear();
707
+ this.realtimeMessageSubscriptions.clear();
534
708
  this.sessionToken = null;
535
709
  this.teamId = null;
536
710
  this.customerId = null;
537
711
  this.authMode = null;
538
712
  this.notifyStateChanged();
713
+ return generation;
714
+ }
715
+ assertSession(generation) {
716
+ if (generation !== this.sessionGeneration) {
717
+ throw new ChatClientAuthError({
718
+ code: "SESSION_CHANGED",
719
+ message: "Your sign-in session changed. This operation belongs to the previous session."
720
+ });
721
+ }
722
+ }
723
+ /** @internal Keep a public operation owned across its asynchronous prerequisites. */
724
+ captureSession() {
725
+ const generation = this.sessionGeneration;
726
+ return () => this.assertSession(generation);
539
727
  }
540
728
  /**
541
729
  * @internal Make an authenticated request to the widget API.
@@ -550,6 +738,12 @@ var ChatClient = class {
550
738
  * server-confirmed mode.
551
739
  */
552
740
  async fetch(path, options) {
741
+ const generation = this.sessionGeneration;
742
+ const assertRequest = () => {
743
+ this.assertSession(generation);
744
+ options.signal?.throwIfAborted();
745
+ };
746
+ assertRequest();
553
747
  const url = this.buildUrl(path, options.query);
554
748
  const headers = {
555
749
  "Content-Type": "application/json"
@@ -562,14 +756,18 @@ var ChatClient = class {
562
756
  response = await globalThis.fetch(url, {
563
757
  method: options.method,
564
758
  headers,
759
+ ...options.signal ? { signal: options.signal } : {},
760
+ ...options.cache ? { cache: options.cache } : {},
565
761
  body: options.body ? JSON.stringify(options.body) : void 0
566
762
  });
567
763
  } catch (networkErr) {
764
+ assertRequest();
568
765
  throw new ChatClientNetworkError({
569
766
  message: networkErr instanceof Error ? networkErr.message : "Network request failed",
570
767
  originalError: networkErr
571
768
  });
572
769
  }
770
+ assertRequest();
573
771
  const responseHeaders = response.headers && typeof response.headers.get === "function" ? response.headers : null;
574
772
  const headerMode = responseHeaders?.get("X-Cstar-Auth-Mode") ?? null;
575
773
  if (headerMode === "hmac" || headerMode === "session") {
@@ -577,9 +775,11 @@ var ChatClient = class {
577
775
  this.authMode = headerMode;
578
776
  if (previous !== headerMode) this.notifyStateChanged();
579
777
  }
778
+ assertRequest();
580
779
  const requestId = responseHeaders?.get("X-Request-Id") ?? null;
581
780
  if (!response.ok) {
582
781
  const errorBody = await response.json().catch(() => ({ error: { code: "UNKNOWN", message: "Request failed" } }));
782
+ assertRequest();
583
783
  const code = errorBody.error?.code ?? "UNKNOWN";
584
784
  const message = errorBody.error?.message ?? `Widget API error: ${response.status}`;
585
785
  if (response.status === 401 || response.status === 403) {
@@ -616,7 +816,14 @@ var ChatClient = class {
616
816
  requestId
617
817
  });
618
818
  }
619
- return response.json();
819
+ try {
820
+ const body = options.readBody ? await options.readBody(response) : await response.json();
821
+ assertRequest();
822
+ return body;
823
+ } catch (error) {
824
+ assertRequest();
825
+ throw error;
826
+ }
620
827
  }
621
828
  /**
622
829
  * Look up the team UUID from the configured `teamSlug`. Used by
@@ -625,37 +832,41 @@ var ChatClient = class {
625
832
  *
626
833
  * @internal
627
834
  */
628
- async resolveTeamIdFromSlug() {
835
+ async resolveTeamIdFromSlug(signal) {
629
836
  const response = await this.fetch(
630
837
  "/api/widget/team-by-slug",
631
838
  {
632
839
  method: "GET",
840
+ signal,
633
841
  query: { teamSlug: this.teamSlug }
634
842
  }
635
843
  );
636
844
  return response.data.teamId;
637
845
  }
638
846
  /** @internal Bootstrap realtime from a session login (no precomputed customerId). */
639
- async initRealtimeFromSession(customerName) {
847
+ async initRealtimeFromSession(customerName, generation) {
848
+ const manager = new RealtimeManager({ fetchToken: () => this.fetchRealtimeToken(generation) });
849
+ this.realtimeManager = manager;
640
850
  try {
641
- this.realtimeManager = new RealtimeManager({
642
- fetchToken: () => this.fetchRealtimeToken()
643
- });
644
- const ready = await this.realtimeManager.init();
851
+ const ready = await manager.init();
852
+ if (generation !== this.sessionGeneration || this.realtimeManager !== manager) {
853
+ manager.destroy();
854
+ return;
855
+ }
645
856
  if (!ready) return;
646
857
  this.realtimeReady = true;
647
- const token = this.realtimeManager.getCurrentToken();
858
+ const token = manager.getCurrentToken();
648
859
  const customerId = token ? decodeJwtSub(token) : null;
649
860
  if (customerId) {
650
861
  this.customerId = customerId;
651
862
  this.presenceManager = new PresenceManager({ customerId, customerName });
652
- const client = this.realtimeManager.getClient();
863
+ const client = manager.getClient();
653
864
  if (client) {
654
865
  this.presenceManager.setClient(client);
655
866
  }
656
867
  }
657
868
  } catch {
658
- this.realtimeReady = false;
869
+ if (generation === this.sessionGeneration) this.realtimeReady = false;
659
870
  }
660
871
  }
661
872
  /** @internal Get the stored team ID for requests that need it. */
@@ -664,23 +875,23 @@ var ChatClient = class {
664
875
  }
665
876
  /**
666
877
  * Resolve a team ID, lazily looking it up from `teamSlug` if no team ID
667
- * has been cached yet. Used by `tickets.list` and friends so that a
668
- * caller who constructed `new ChatClient({ teamSlug, publishableKey })`
669
- * doesn't have to manually call `loginWithCredentials` before reading
670
- * public data.
671
- *
672
- * Closes NEW-v3-05 — `TicketsState(chatClient)` previously errored
673
- * `"Missing teamId"` when the chat client was teamSlug-only because the
674
- * tickets sub-resource read `getTeamId()` synchronously and bailed.
878
+ * has been cached yet. Authenticated resources and realtime bootstrap
879
+ * use this after session login so callers do not need to list tickets
880
+ * or set a UUID before opening an existing conversation.
675
881
  */
676
- async ensureTeamId() {
882
+ async ensureTeamId(signal) {
883
+ const assertCurrent = this.captureSession();
884
+ signal?.throwIfAborted();
677
885
  if (this.teamId) return this.teamId;
678
886
  if (!this.teamSlug) {
679
887
  throw new ChatClientAuthError(
680
888
  "Missing teamId. Construct ChatClient with `teamSlug` or call `setTeamId()` / `identify()` / `loginWithCredentials()` first."
681
889
  );
682
890
  }
683
- this.teamId = await this.resolveTeamIdFromSlug();
891
+ const teamId = await this.resolveTeamIdFromSlug(signal);
892
+ assertCurrent();
893
+ signal?.throwIfAborted();
894
+ this.teamId = teamId;
684
895
  return this.teamId;
685
896
  }
686
897
  /** @internal Set team ID from identify response or other source. */
@@ -693,63 +904,116 @@ var ChatClient = class {
693
904
  await this.realtimeInitPromise;
694
905
  }
695
906
  }
696
- async initRealtime(customerId, customerName) {
907
+ async initRealtime(customerId, customerName, generation) {
908
+ const manager = new RealtimeManager({ fetchToken: () => this.fetchRealtimeToken(generation) });
909
+ this.realtimeManager = manager;
697
910
  try {
698
- this.realtimeManager = new RealtimeManager({
699
- fetchToken: () => this.fetchRealtimeToken()
700
- });
701
- const ready = await this.realtimeManager.init();
911
+ const ready = await manager.init();
912
+ if (generation !== this.sessionGeneration || this.realtimeManager !== manager) {
913
+ manager.destroy();
914
+ return;
915
+ }
702
916
  if (ready) {
703
917
  this.realtimeReady = true;
704
918
  this.presenceManager = new PresenceManager({
705
919
  customerId,
706
920
  customerName
707
921
  });
708
- const client = this.realtimeManager.getClient();
922
+ const client = manager.getClient();
709
923
  if (client) {
710
924
  this.presenceManager.setClient(client);
711
925
  }
712
926
  }
713
927
  } catch {
714
- this.realtimeReady = false;
928
+ if (generation === this.sessionGeneration) this.realtimeReady = false;
715
929
  }
716
930
  }
717
- async fetchRealtimeToken() {
931
+ async fetchRealtimeToken(generation) {
932
+ this.assertSession(generation);
933
+ const teamId = await this.ensureTeamId();
934
+ this.assertSession(generation);
718
935
  const response = await this.fetch(
719
936
  "/api/widget/realtime-token",
720
937
  {
721
938
  method: "POST",
722
- body: { teamId: this.teamId }
939
+ body: { teamId }
723
940
  }
724
941
  );
942
+ this.assertSession(generation);
725
943
  return response.data;
726
944
  }
727
- pollMessages(ticketId, callback) {
728
- let lastMessageId = null;
729
- const poll = async () => {
945
+ pollMessages(ticketId, callback, manager) {
946
+ const seen = /* @__PURE__ */ new Set();
947
+ let initialized = false;
948
+ let active = true;
949
+ let inFlight = false;
950
+ let catchUpQueued = false;
951
+ let connected = false;
952
+ let timer;
953
+ let stopRealtime;
954
+ const deliver = (message) => {
955
+ if (!active || seen.has(message.id)) return;
956
+ seen.add(message.id);
957
+ callback(message);
958
+ };
959
+ const stopPolling = () => {
960
+ clearInterval(timer);
961
+ timer = void 0;
962
+ };
963
+ const startPolling = () => {
964
+ if (active && timer === void 0) {
965
+ timer = setInterval(() => void poll(), this.pollingInterval);
966
+ }
967
+ };
968
+ const poll = async (catchUp = false) => {
969
+ if (!active) return;
970
+ if (inFlight) {
971
+ if (catchUp) catchUpQueued = true;
972
+ return;
973
+ }
974
+ inFlight = true;
730
975
  try {
731
- const messages = await this.messages.list(ticketId);
732
- if (messages.length > 0) {
733
- const latest = messages[messages.length - 1];
734
- if (lastMessageId && latest.id !== lastMessageId) {
735
- const lastIdx = messages.findIndex((m) => m.id === lastMessageId);
736
- const newMessages = lastIdx >= 0 ? messages.slice(lastIdx + 1) : [latest];
737
- for (const msg of newMessages) {
738
- callback(msg);
976
+ do {
977
+ catchUpQueued = false;
978
+ try {
979
+ const messages = await this.messages.list(ticketId);
980
+ if (!active) return;
981
+ for (const message of messages) {
982
+ if (!active) return;
983
+ if (initialized) deliver(message);
984
+ else seen.add(message.id);
739
985
  }
986
+ initialized = true;
987
+ if (connected) stopPolling();
988
+ } catch {
989
+ startPolling();
740
990
  }
741
- lastMessageId = latest.id;
742
- }
743
- } catch {
991
+ } while (active && catchUpQueued);
992
+ } finally {
993
+ inFlight = false;
744
994
  }
745
995
  };
746
- poll();
747
- const timer = setInterval(poll, this.pollingInterval);
748
- this.pollingTimers.set(ticketId, timer);
749
- return () => {
750
- clearInterval(timer);
751
- this.pollingTimers.delete(ticketId);
996
+ const unsubscribe = () => {
997
+ active = false;
998
+ stopPolling();
999
+ stopRealtime?.();
1000
+ this.pollingSubscriptions.delete(unsubscribe);
752
1001
  };
1002
+ this.pollingSubscriptions.add(unsubscribe);
1003
+ void poll();
1004
+ startPolling();
1005
+ stopRealtime = manager?.subscribeToMessages(ticketId, deliver, (status) => {
1006
+ if (!active) return;
1007
+ if (status === "SUBSCRIBED") {
1008
+ connected = true;
1009
+ stopPolling();
1010
+ } else if (["CHANNEL_ERROR", "TIMED_OUT", "CLOSED"].includes(status)) {
1011
+ connected = false;
1012
+ startPolling();
1013
+ } else return;
1014
+ void poll(true);
1015
+ });
1016
+ return unsubscribe;
753
1017
  }
754
1018
  buildUrl(path, query) {
755
1019
  const base = `${this.baseUrl}${path}`;
@@ -768,10 +1032,12 @@ var TicketsApi = class {
768
1032
  }
769
1033
  /** List tickets for the identified customer. */
770
1034
  async list(params) {
1035
+ const assertCurrent = this.chat.captureSession();
771
1036
  if (this.chat.getAuthMode() === null) {
772
1037
  throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
773
1038
  }
774
1039
  const teamId = await this.chat.ensureTeamId();
1040
+ assertCurrent();
775
1041
  const response = await this.chat.fetch("/api/widget/conversations", {
776
1042
  method: "GET",
777
1043
  query: {
@@ -780,6 +1046,7 @@ var TicketsApi = class {
780
1046
  cursor: params?.cursor
781
1047
  }
782
1048
  });
1049
+ assertCurrent();
783
1050
  const data = response.data.tickets ?? response.data.conversations ?? [];
784
1051
  const cursor = response.data.cursor;
785
1052
  const hasMore = response.data.hasMore;
@@ -791,10 +1058,12 @@ var TicketsApi = class {
791
1058
  }
792
1059
  /** Create a new ticket. */
793
1060
  async create(params) {
1061
+ const assertCurrent = this.chat.captureSession();
794
1062
  if (this.chat.getAuthMode() === null) {
795
1063
  throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
796
1064
  }
797
1065
  const teamId = await this.chat.ensureTeamId();
1066
+ assertCurrent();
798
1067
  const response = await this.chat.fetch("/api/widget/tickets", {
799
1068
  method: "POST",
800
1069
  body: {
@@ -803,6 +1072,7 @@ var TicketsApi = class {
803
1072
  message: params.message
804
1073
  }
805
1074
  });
1075
+ assertCurrent();
806
1076
  const messages = (response.data.messages ?? []).map((m) => ({
807
1077
  id: m.id,
808
1078
  content: m.content,
@@ -831,8 +1101,12 @@ var ChatCommunityApi = class {
831
1101
  }
832
1102
  /** Create a new community post as the identified customer. */
833
1103
  async createPost(params) {
834
- const teamId = this.chat.getTeamId();
835
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1104
+ const assertCurrent = this.chat.captureSession();
1105
+ if (this.chat.getAuthMode() === null) {
1106
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1107
+ }
1108
+ const teamId = await this.chat.ensureTeamId();
1109
+ assertCurrent();
836
1110
  const response = await this.chat.fetch("/api/widget/community", {
837
1111
  method: "POST",
838
1112
  body: {
@@ -844,12 +1118,17 @@ var ChatCommunityApi = class {
844
1118
  customFields: params.customFields
845
1119
  }
846
1120
  });
1121
+ assertCurrent();
847
1122
  return response.data.post;
848
1123
  }
849
1124
  /** Update a post. Only the original author can edit. */
850
1125
  async updatePost(postId, params) {
851
- const teamId = this.chat.getTeamId();
852
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1126
+ const assertCurrent = this.chat.captureSession();
1127
+ if (this.chat.getAuthMode() === null) {
1128
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1129
+ }
1130
+ const teamId = await this.chat.ensureTeamId();
1131
+ assertCurrent();
853
1132
  const response = await this.chat.fetch("/api/widget/community", {
854
1133
  method: "POST",
855
1134
  body: {
@@ -861,21 +1140,31 @@ var ChatCommunityApi = class {
861
1140
  customFields: params.customFields
862
1141
  }
863
1142
  });
1143
+ assertCurrent();
864
1144
  return response.data.post;
865
1145
  }
866
1146
  /** Delete a post (soft delete). Only the original author can delete. */
867
1147
  async deletePost(postId) {
868
- const teamId = this.chat.getTeamId();
869
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1148
+ const assertCurrent = this.chat.captureSession();
1149
+ if (this.chat.getAuthMode() === null) {
1150
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1151
+ }
1152
+ const teamId = await this.chat.ensureTeamId();
1153
+ assertCurrent();
870
1154
  await this.chat.fetch("/api/widget/community", {
871
1155
  method: "POST",
872
1156
  body: { teamId, action: "deletePost", postId }
873
1157
  });
1158
+ assertCurrent();
874
1159
  }
875
1160
  /** Update a comment. Only the original author can edit. */
876
1161
  async updateComment(postId, commentId, params) {
877
- const teamId = this.chat.getTeamId();
878
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1162
+ const assertCurrent = this.chat.captureSession();
1163
+ if (this.chat.getAuthMode() === null) {
1164
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1165
+ }
1166
+ const teamId = await this.chat.ensureTeamId();
1167
+ assertCurrent();
879
1168
  const response = await this.chat.fetch("/api/widget/community", {
880
1169
  method: "POST",
881
1170
  body: {
@@ -886,31 +1175,46 @@ var ChatCommunityApi = class {
886
1175
  commentBody: params.body
887
1176
  }
888
1177
  });
1178
+ assertCurrent();
889
1179
  return response.data.comment;
890
1180
  }
891
1181
  /** Delete a comment (soft delete). Only the original author can delete. */
892
1182
  async deleteComment(postId, commentId) {
893
- const teamId = this.chat.getTeamId();
894
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1183
+ const assertCurrent = this.chat.captureSession();
1184
+ if (this.chat.getAuthMode() === null) {
1185
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1186
+ }
1187
+ const teamId = await this.chat.ensureTeamId();
1188
+ assertCurrent();
895
1189
  await this.chat.fetch("/api/widget/community", {
896
1190
  method: "POST",
897
1191
  body: { teamId, action: "deleteComment", postId, commentId }
898
1192
  });
1193
+ assertCurrent();
899
1194
  }
900
1195
  /** Toggle vote on a post. Returns new vote state and count. */
901
1196
  async vote(postId) {
902
- const teamId = this.chat.getTeamId();
903
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1197
+ const assertCurrent = this.chat.captureSession();
1198
+ if (this.chat.getAuthMode() === null) {
1199
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1200
+ }
1201
+ const teamId = await this.chat.ensureTeamId();
1202
+ assertCurrent();
904
1203
  const response = await this.chat.fetch("/api/widget/community", {
905
1204
  method: "POST",
906
1205
  body: { teamId, action: "vote", postId }
907
1206
  });
1207
+ assertCurrent();
908
1208
  return response.data;
909
1209
  }
910
1210
  /** Add a comment to a post. Supports 1-level nesting via parentCommentId. */
911
1211
  async comment(postId, params) {
912
- const teamId = this.chat.getTeamId();
913
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1212
+ const assertCurrent = this.chat.captureSession();
1213
+ if (this.chat.getAuthMode() === null) {
1214
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1215
+ }
1216
+ const teamId = await this.chat.ensureTeamId();
1217
+ assertCurrent();
914
1218
  const response = await this.chat.fetch("/api/widget/community", {
915
1219
  method: "POST",
916
1220
  body: {
@@ -921,37 +1225,53 @@ var ChatCommunityApi = class {
921
1225
  parentCommentId: params.parentCommentId ?? null
922
1226
  }
923
1227
  });
1228
+ assertCurrent();
924
1229
  return response.data.comment;
925
1230
  }
926
1231
  /** Toggle follow on a post. */
927
1232
  async follow(postId) {
928
- const teamId = this.chat.getTeamId();
929
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1233
+ const assertCurrent = this.chat.captureSession();
1234
+ if (this.chat.getAuthMode() === null) {
1235
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1236
+ }
1237
+ const teamId = await this.chat.ensureTeamId();
1238
+ assertCurrent();
930
1239
  const response = await this.chat.fetch("/api/widget/community", {
931
1240
  method: "POST",
932
1241
  body: { teamId, action: "follow", postId }
933
1242
  });
1243
+ assertCurrent();
934
1244
  return response.data;
935
1245
  }
936
1246
  /** Check if the current user has voted on a post. */
937
1247
  async getVoteStatus(postId) {
938
- const teamId = this.chat.getTeamId();
939
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1248
+ const assertCurrent = this.chat.captureSession();
1249
+ if (this.chat.getAuthMode() === null) {
1250
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1251
+ }
1252
+ const teamId = await this.chat.ensureTeamId();
1253
+ assertCurrent();
940
1254
  const response = await this.chat.fetch("/api/widget/community", {
941
1255
  method: "GET",
942
1256
  query: { teamId, postId }
943
1257
  });
1258
+ assertCurrent();
944
1259
  return response.data.voted;
945
1260
  }
946
1261
  /** Check vote status for multiple posts in one request. */
947
1262
  async bulkVoteStatus(postIds) {
948
1263
  if (postIds.length === 0) return {};
949
- const teamId = this.chat.getTeamId();
950
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1264
+ const assertCurrent = this.chat.captureSession();
1265
+ if (this.chat.getAuthMode() === null) {
1266
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1267
+ }
1268
+ const teamId = await this.chat.ensureTeamId();
1269
+ assertCurrent();
951
1270
  const response = await this.chat.fetch("/api/widget/community", {
952
1271
  method: "GET",
953
1272
  query: { teamId, postIds: postIds.join(",") }
954
1273
  });
1274
+ assertCurrent();
955
1275
  return response.data;
956
1276
  }
957
1277
  };
@@ -961,22 +1281,27 @@ var MessagesApi = class {
961
1281
  }
962
1282
  /** List messages for a ticket. */
963
1283
  async list(ticketId) {
964
- const teamId = this.chat.getTeamId();
1284
+ const assertCurrent = this.chat.captureSession();
1285
+ const teamId = await this.chat.ensureTeamId();
1286
+ assertCurrent();
965
1287
  const response = await this.chat.fetch(
966
1288
  "/api/widget/messages",
967
1289
  {
968
1290
  method: "GET",
969
1291
  query: {
970
- teamId: teamId ?? void 0,
1292
+ teamId,
971
1293
  ticketId
972
1294
  }
973
1295
  }
974
1296
  );
1297
+ assertCurrent();
975
1298
  return response.data.messages;
976
1299
  }
977
1300
  /** Send a message to a ticket. */
978
1301
  async send(ticketId, content) {
979
- const teamId = this.chat.getTeamId();
1302
+ const assertCurrent = this.chat.captureSession();
1303
+ const teamId = await this.chat.ensureTeamId();
1304
+ assertCurrent();
980
1305
  const response = await this.chat.fetch(
981
1306
  "/api/widget/messages",
982
1307
  {
@@ -988,6 +1313,7 @@ var MessagesApi = class {
988
1313
  }
989
1314
  }
990
1315
  );
1316
+ assertCurrent();
991
1317
  return response.data.message;
992
1318
  }
993
1319
  };