@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.
@@ -1,4 +1,26 @@
1
1
  // src/chat/realtime.ts
2
+ var channelRemovals = /* @__PURE__ */ new WeakMap();
3
+ var CHANNEL_DRAIN_TIMEOUT_MS = 5e3;
4
+ function removeRealtimeChannel(client, channel) {
5
+ const pending = channelRemovals.get(client) ?? /* @__PURE__ */ new Set();
6
+ channelRemovals.set(client, pending);
7
+ const removal = client.removeChannel(channel);
8
+ pending.add(removal);
9
+ const settled = () => pending.delete(removal);
10
+ void removal.then(settled, settled);
11
+ }
12
+ async function closeRealtimeClient(client) {
13
+ const pending = [...channelRemovals.get(client) ?? []];
14
+ if (pending.length) {
15
+ let timeout;
16
+ const deadline = new Promise((resolve) => {
17
+ timeout = setTimeout(resolve, CHANNEL_DRAIN_TIMEOUT_MS);
18
+ });
19
+ await Promise.race([Promise.allSettled(pending), deadline]);
20
+ clearTimeout(timeout);
21
+ }
22
+ await client.realtime.disconnect();
23
+ }
2
24
  var RealtimeManager = class {
3
25
  constructor(options) {
4
26
  this.client = null;
@@ -6,6 +28,8 @@ var RealtimeManager = class {
6
28
  this.channels = /* @__PURE__ */ new Map();
7
29
  this.refreshTimer = null;
8
30
  this.currentToken = null;
31
+ this.destroyed = false;
32
+ this.cleanupPromise = null;
9
33
  this.options = options;
10
34
  }
11
35
  /**
@@ -13,6 +37,7 @@ var RealtimeManager = class {
13
37
  * Returns true if successful, false if the realtime runtime is unavailable.
14
38
  */
15
39
  init() {
40
+ if (this.destroyed) return Promise.resolve(false);
16
41
  if (this.initPromise) return this.initPromise;
17
42
  this.initPromise = this.doInit();
18
43
  return this.initPromise;
@@ -20,11 +45,17 @@ var RealtimeManager = class {
20
45
  async doInit() {
21
46
  try {
22
47
  const tokenData = await this.options.fetchToken();
48
+ if (this.destroyed) return false;
23
49
  const { createClient } = await import(
24
50
  /* webpackIgnore: true */
25
51
  "@supabase/supabase-js"
26
52
  );
53
+ if (this.destroyed) return false;
54
+ this.currentToken = tokenData.token;
27
55
  this.client = createClient(tokenData.realtimeUrl, tokenData.realtimeKey, {
56
+ // Reconnects and heartbeats must refresh from cStar's customer JWT,
57
+ // rather than the dedicated client's empty hosted-auth session.
58
+ accessToken: async () => this.currentToken,
28
59
  auth: { persistSession: false, autoRefreshToken: false },
29
60
  realtime: { params: { eventsPerSecond: 10 } }
30
61
  });
@@ -51,10 +82,13 @@ var RealtimeManager = class {
51
82
  this.refreshTimer = setTimeout(() => this.doRefresh(), delay);
52
83
  }
53
84
  async doRefresh() {
85
+ if (this.destroyed) return;
54
86
  try {
55
87
  const tokenData = await this.options.fetchToken();
88
+ if (this.destroyed) return;
56
89
  this.setToken(tokenData);
57
90
  } catch {
91
+ if (this.destroyed) return;
58
92
  this.refreshTimer = setTimeout(() => this.doRefresh(), 3e4);
59
93
  }
60
94
  }
@@ -62,12 +96,14 @@ var RealtimeManager = class {
62
96
  * Subscribe to new messages on a ticket via Postgres Changes.
63
97
  * Returns an unsubscribe function.
64
98
  */
65
- subscribeToMessages(ticketId, callback) {
99
+ subscribeToMessages(ticketId, callback, onStatus) {
66
100
  if (!this.client) return () => {
67
101
  };
68
102
  const channelName = `messages:${ticketId}`;
69
103
  this.removeChannel(channelName);
70
- const channel = this.client.channel(channelName).on(
104
+ const channel = this.client.channel(channelName);
105
+ this.channels.set(channelName, channel);
106
+ channel.on(
71
107
  "postgres_changes",
72
108
  {
73
109
  event: "INSERT",
@@ -76,6 +112,7 @@ var RealtimeManager = class {
76
112
  filter: `ticket_id=eq.${ticketId}`
77
113
  },
78
114
  (payload) => {
115
+ if (this.channels.get(channelName) !== channel) return;
79
116
  const row = payload.new;
80
117
  if (!row) return;
81
118
  const message = {
@@ -90,9 +127,10 @@ var RealtimeManager = class {
90
127
  };
91
128
  callback(message);
92
129
  }
93
- ).subscribe();
94
- this.channels.set(channelName, channel);
95
- return () => this.removeChannel(channelName);
130
+ ).subscribe((status) => {
131
+ if (this.channels.get(channelName) === channel) onStatus?.(status);
132
+ });
133
+ return () => this.removeChannel(channelName, channel);
96
134
  }
97
135
  /** Get the underlying realtime client (for sharing with PresenceManager). */
98
136
  getClient() {
@@ -110,15 +148,18 @@ var RealtimeManager = class {
110
148
  get isReady() {
111
149
  return this.client !== null && this.currentToken !== null;
112
150
  }
113
- removeChannel(name) {
151
+ removeChannel(name, expected) {
114
152
  const existing = this.channels.get(name);
153
+ if (expected && existing !== expected) return;
115
154
  if (existing && this.client) {
116
- this.client.removeChannel(existing);
117
155
  this.channels.delete(name);
156
+ removeRealtimeChannel(this.client, existing);
118
157
  }
119
158
  }
120
159
  /** Clean up all channels, timers, and the client. */
121
160
  destroy() {
161
+ if (this.destroyed) return this.cleanupPromise ?? Promise.resolve();
162
+ this.destroyed = true;
122
163
  if (this.refreshTimer) {
123
164
  clearTimeout(this.refreshTimer);
124
165
  this.refreshTimer = null;
@@ -127,9 +168,13 @@ var RealtimeManager = class {
127
168
  this.removeChannel(name);
128
169
  }
129
170
  this.channels.clear();
171
+ const client = this.client;
130
172
  this.client = null;
131
173
  this.initPromise = null;
132
174
  this.currentToken = null;
175
+ this.cleanupPromise = client ? closeRealtimeClient(client).catch(() => {
176
+ }) : Promise.resolve();
177
+ return this.cleanupPromise;
133
178
  }
134
179
  };
135
180
 
@@ -159,7 +204,10 @@ var PresenceManager = class {
159
204
  };
160
205
  const channelName = `typing:${ticketId}`;
161
206
  this.removeChannel(channelName);
162
- const channel = this.client.channel(`ticket:${ticketId}`).on("broadcast", { event: "typing" }, (data) => {
207
+ const channel = this.client.channel(`ticket:${ticketId}`);
208
+ this.channels.set(channelName, channel);
209
+ channel.on("broadcast", { event: "typing" }, (data) => {
210
+ if (this.channels.get(channelName) !== channel) return;
163
211
  const payload = data?.payload;
164
212
  if (!payload) return;
165
213
  if (payload.agent_id) {
@@ -171,8 +219,7 @@ var PresenceManager = class {
171
219
  callback(typingPayload);
172
220
  }
173
221
  }).subscribe();
174
- this.channels.set(channelName, channel);
175
- return () => this.removeChannel(channelName);
222
+ return () => this.removeChannel(channelName, channel);
176
223
  }
177
224
  /**
178
225
  * Send a customer typing indicator.
@@ -225,11 +272,12 @@ var PresenceManager = class {
225
272
  } catch {
226
273
  }
227
274
  }
228
- removeChannel(name) {
275
+ removeChannel(name, expected) {
229
276
  const existing = this.channels.get(name);
277
+ if (expected && existing !== expected) return;
230
278
  if (existing && this.client) {
231
- this.client.removeChannel(existing);
232
279
  this.channels.delete(name);
280
+ removeRealtimeChannel(this.client, existing);
233
281
  }
234
282
  }
235
283
  /** Clean up all channels and timers. */
@@ -292,6 +340,94 @@ var ChatClientNetworkError = class extends ChatClientError {
292
340
  }
293
341
  };
294
342
 
343
+ // src/chat/attachments.ts
344
+ var UUID = /^[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
345
+ function downloadFilename(header) {
346
+ const extended = header?.match(/(?:^|;)\s*filename\*\s*=\s*UTF-8''([^;]*)/i);
347
+ let name = "";
348
+ if (extended) {
349
+ try {
350
+ name = decodeURIComponent(extended[1]);
351
+ } catch {
352
+ }
353
+ }
354
+ if (!name) {
355
+ const fallback = header?.match(/(?:^|;)\s*filename\s*=\s*(?:"((?:\\.|[^"\\])*)"|([^;]*))/i);
356
+ name = fallback?.[1]?.replace(/\\(.)/g, "$1") ?? fallback?.[2] ?? "";
357
+ }
358
+ return name.split(/[\\/]/).pop()?.replace(/[\x00-\x1f\x7f]/g, "").replace(/^[\s.]+|[\s.]+$/g, "") || "download";
359
+ }
360
+ var AttachmentsApi = class {
361
+ constructor(chat) {
362
+ this.chat = chat;
363
+ }
364
+ async download(reference, options = {}) {
365
+ const assertCurrent = this.chat.captureSession();
366
+ if (!this.chat.getAuthMode())
367
+ throw new ChatClientAuthError("Sign in before downloading a file.");
368
+ const { ticketId, messageId, attachmentIndex } = reference;
369
+ if (typeof ticketId !== "string" || typeof messageId !== "string" || !UUID.test(ticketId) || !UUID.test(messageId) || !Number.isSafeInteger(attachmentIndex) || attachmentIndex < 0) {
370
+ throw new TypeError("A saved ticket, message and nonnegative attachment index are required.");
371
+ }
372
+ const controller = new AbortController();
373
+ const signal = options.signal;
374
+ const download = options.download === false ? "0" : "1";
375
+ const abort = () => controller.abort();
376
+ const assertOwned = () => {
377
+ assertCurrent();
378
+ controller.signal.throwIfAborted();
379
+ };
380
+ const unsubscribe = this.chat.subscribe(() => {
381
+ try {
382
+ assertCurrent();
383
+ } catch {
384
+ abort();
385
+ }
386
+ });
387
+ signal?.addEventListener("abort", abort, { once: true });
388
+ if (signal?.aborted) abort();
389
+ try {
390
+ assertOwned();
391
+ const teamId = await this.chat.ensureTeamId(controller.signal);
392
+ assertOwned();
393
+ const result = await this.chat.fetch(
394
+ "/api/widget/attachments/download",
395
+ {
396
+ method: "GET",
397
+ query: {
398
+ teamId,
399
+ ticketId,
400
+ messageId,
401
+ attachmentIndex: String(attachmentIndex),
402
+ download
403
+ },
404
+ signal: controller.signal,
405
+ cache: "no-store",
406
+ readBody: async (response) => {
407
+ assertOwned();
408
+ const blob = await response.blob();
409
+ assertOwned();
410
+ return {
411
+ blob,
412
+ filename: downloadFilename(response.headers.get("Content-Disposition")),
413
+ contentType: blob.type.split(";")[0] || "application/octet-stream",
414
+ size: blob.size
415
+ };
416
+ }
417
+ }
418
+ );
419
+ assertOwned();
420
+ return result;
421
+ } catch (error) {
422
+ assertOwned();
423
+ throw error;
424
+ } finally {
425
+ unsubscribe();
426
+ signal?.removeEventListener("abort", abort);
427
+ }
428
+ }
429
+ };
430
+
295
431
  // src/chat/index.ts
296
432
  var DEFAULT_BASE_URL = "https://www.cstar.help";
297
433
  var DEFAULT_POLLING_INTERVAL = 3e3;
@@ -301,7 +437,9 @@ var ChatClient = class {
301
437
  this.teamId = null;
302
438
  this.customerId = null;
303
439
  this.authMode = null;
304
- this.pollingTimers = /* @__PURE__ */ new Map();
440
+ this.sessionGeneration = 0;
441
+ this.pollingSubscriptions = /* @__PURE__ */ new Set();
442
+ this.realtimeMessageSubscriptions = /* @__PURE__ */ new Map();
305
443
  this.stateListeners = /* @__PURE__ */ new Set();
306
444
  this.realtimeManager = null;
307
445
  this.presenceManager = null;
@@ -313,6 +451,7 @@ var ChatClient = class {
313
451
  this.realtimeEnabled = config.realtime !== false;
314
452
  this.tickets = new TicketsApi(this);
315
453
  this.messages = new MessagesApi(this);
454
+ this.attachments = new AttachmentsApi(this);
316
455
  this.community = new ChatCommunityApi(this);
317
456
  }
318
457
  /**
@@ -327,6 +466,8 @@ var ChatClient = class {
327
466
  * @param testMode - Whether to use test mode (relaxed timestamp validation)
328
467
  */
329
468
  async identify(customer, signature, testMode = false) {
469
+ const generation = this.retireSession();
470
+ this.assertSession(generation);
330
471
  const response = await this.fetch(
331
472
  "/api/widget/verify-identity",
332
473
  {
@@ -339,15 +480,18 @@ var ChatClient = class {
339
480
  }
340
481
  }
341
482
  );
483
+ this.assertSession(generation);
342
484
  this.sessionToken = response.data.sessionToken;
343
485
  this.teamId = response.data.teamId;
344
486
  this.customerId = response.data.customer.id;
345
487
  this.authMode = "hmac";
346
488
  this.notifyStateChanged();
489
+ this.assertSession(generation);
347
490
  if (this.realtimeEnabled) {
348
491
  this.realtimeInitPromise = this.initRealtime(
349
492
  response.data.customer.id,
350
- response.data.customer.name || customer.name || customer.email
493
+ response.data.customer.name || customer.name || customer.email,
494
+ generation
351
495
  );
352
496
  }
353
497
  return response.data;
@@ -362,11 +506,15 @@ var ChatClient = class {
362
506
  * Supabase session, use `loginWithSession({ accessToken })` instead.
363
507
  */
364
508
  async loginWithCredentials(params) {
509
+ const generation = this.retireSession();
510
+ this.assertSession(generation);
365
511
  const teamId = await this.resolveTeamIdFromSlug();
512
+ this.assertSession(generation);
366
513
  const response = await this.fetch("/api/widget/auth/login", {
367
514
  method: "POST",
368
515
  body: { email: params.email, password: params.password, teamId }
369
516
  });
517
+ this.assertSession(generation);
370
518
  if (!response.accessToken) {
371
519
  throw new ChatClientAuthError({
372
520
  message: "Login succeeded but no session was issued",
@@ -375,11 +523,14 @@ var ChatClient = class {
375
523
  requestId: null
376
524
  });
377
525
  }
378
- await this.loginWithSession({
379
- accessToken: response.accessToken,
380
- teamId,
381
- customerName: response.customer.name ?? response.customer.email
382
- });
526
+ this.applySession(
527
+ {
528
+ accessToken: response.accessToken,
529
+ teamId,
530
+ customerName: response.customer.name ?? response.customer.email
531
+ },
532
+ generation
533
+ );
383
534
  return {
384
535
  customer: {
385
536
  id: this.customerId ?? response.customer.id,
@@ -398,15 +549,22 @@ var ChatClient = class {
398
549
  * every request — there is no client-side validation here.
399
550
  */
400
551
  async loginWithSession(params) {
552
+ const teamId = params.teamId ?? this.teamId;
553
+ const generation = this.retireSession();
554
+ this.applySession({ ...params, ...teamId ? { teamId } : {} }, generation);
555
+ }
556
+ applySession(params, generation) {
557
+ this.assertSession(generation);
401
558
  this.sessionToken = params.accessToken;
402
559
  if (params.teamId) {
403
560
  this.teamId = params.teamId;
404
561
  }
405
562
  this.authMode = "session";
406
563
  this.notifyStateChanged();
564
+ this.assertSession(generation);
407
565
  if (this.realtimeEnabled) {
408
566
  const customerName = params.customerName ?? "";
409
- this.realtimeInitPromise = this.initRealtimeFromSession(customerName);
567
+ this.realtimeInitPromise = this.initRealtimeFromSession(customerName, generation);
410
568
  }
411
569
  }
412
570
  /**
@@ -448,7 +606,15 @@ var ChatClient = class {
448
606
  */
449
607
  onMessage(ticketId, callback) {
450
608
  if (this.realtimeReady && this.realtimeManager) {
451
- return this.realtimeManager.subscribeToMessages(ticketId, callback);
609
+ this.realtimeMessageSubscriptions.get(ticketId)?.();
610
+ const unsubscribe = this.pollMessages(ticketId, callback, this.realtimeManager);
611
+ this.realtimeMessageSubscriptions.set(ticketId, unsubscribe);
612
+ return () => {
613
+ unsubscribe();
614
+ if (this.realtimeMessageSubscriptions.get(ticketId) === unsubscribe) {
615
+ this.realtimeMessageSubscriptions.delete(ticketId);
616
+ }
617
+ };
452
618
  }
453
619
  return this.pollMessages(ticketId, callback);
454
620
  }
@@ -459,8 +625,11 @@ var ChatClient = class {
459
625
  * @returns Unsubscribe function
460
626
  */
461
627
  onTyping(ticketId, callback) {
628
+ const generation = this.sessionGeneration;
462
629
  if (this.realtimeReady && this.presenceManager) {
463
- return this.presenceManager.subscribeToTyping(ticketId, callback);
630
+ return this.presenceManager.subscribeToTyping(ticketId, (typing) => {
631
+ if (generation === this.sessionGeneration) callback(typing);
632
+ });
464
633
  }
465
634
  return () => {
466
635
  };
@@ -476,25 +645,44 @@ var ChatClient = class {
476
645
  }
477
646
  /** Disconnect all subscriptions and clean up. */
478
647
  disconnect() {
479
- if (this.realtimeManager) {
480
- this.realtimeManager.destroy();
481
- this.realtimeManager = null;
482
- }
648
+ this.retireSession();
649
+ }
650
+ retireSession() {
651
+ const generation = ++this.sessionGeneration;
483
652
  if (this.presenceManager) {
484
653
  this.presenceManager.destroy();
485
654
  this.presenceManager = null;
486
655
  }
656
+ if (this.realtimeManager) {
657
+ this.realtimeManager.destroy();
658
+ this.realtimeManager = null;
659
+ }
487
660
  this.realtimeReady = false;
488
661
  this.realtimeInitPromise = null;
489
- for (const timer of this.pollingTimers.values()) {
490
- clearInterval(timer);
662
+ for (const unsubscribe of this.pollingSubscriptions) {
663
+ unsubscribe();
491
664
  }
492
- this.pollingTimers.clear();
665
+ this.pollingSubscriptions.clear();
666
+ this.realtimeMessageSubscriptions.clear();
493
667
  this.sessionToken = null;
494
668
  this.teamId = null;
495
669
  this.customerId = null;
496
670
  this.authMode = null;
497
671
  this.notifyStateChanged();
672
+ return generation;
673
+ }
674
+ assertSession(generation) {
675
+ if (generation !== this.sessionGeneration) {
676
+ throw new ChatClientAuthError({
677
+ code: "SESSION_CHANGED",
678
+ message: "Your sign-in session changed. This operation belongs to the previous session."
679
+ });
680
+ }
681
+ }
682
+ /** @internal Keep a public operation owned across its asynchronous prerequisites. */
683
+ captureSession() {
684
+ const generation = this.sessionGeneration;
685
+ return () => this.assertSession(generation);
498
686
  }
499
687
  /**
500
688
  * @internal Make an authenticated request to the widget API.
@@ -509,6 +697,12 @@ var ChatClient = class {
509
697
  * server-confirmed mode.
510
698
  */
511
699
  async fetch(path, options) {
700
+ const generation = this.sessionGeneration;
701
+ const assertRequest = () => {
702
+ this.assertSession(generation);
703
+ options.signal?.throwIfAborted();
704
+ };
705
+ assertRequest();
512
706
  const url = this.buildUrl(path, options.query);
513
707
  const headers = {
514
708
  "Content-Type": "application/json"
@@ -521,14 +715,18 @@ var ChatClient = class {
521
715
  response = await globalThis.fetch(url, {
522
716
  method: options.method,
523
717
  headers,
718
+ ...options.signal ? { signal: options.signal } : {},
719
+ ...options.cache ? { cache: options.cache } : {},
524
720
  body: options.body ? JSON.stringify(options.body) : void 0
525
721
  });
526
722
  } catch (networkErr) {
723
+ assertRequest();
527
724
  throw new ChatClientNetworkError({
528
725
  message: networkErr instanceof Error ? networkErr.message : "Network request failed",
529
726
  originalError: networkErr
530
727
  });
531
728
  }
729
+ assertRequest();
532
730
  const responseHeaders = response.headers && typeof response.headers.get === "function" ? response.headers : null;
533
731
  const headerMode = responseHeaders?.get("X-Cstar-Auth-Mode") ?? null;
534
732
  if (headerMode === "hmac" || headerMode === "session") {
@@ -536,9 +734,11 @@ var ChatClient = class {
536
734
  this.authMode = headerMode;
537
735
  if (previous !== headerMode) this.notifyStateChanged();
538
736
  }
737
+ assertRequest();
539
738
  const requestId = responseHeaders?.get("X-Request-Id") ?? null;
540
739
  if (!response.ok) {
541
740
  const errorBody = await response.json().catch(() => ({ error: { code: "UNKNOWN", message: "Request failed" } }));
741
+ assertRequest();
542
742
  const code = errorBody.error?.code ?? "UNKNOWN";
543
743
  const message = errorBody.error?.message ?? `Widget API error: ${response.status}`;
544
744
  if (response.status === 401 || response.status === 403) {
@@ -575,7 +775,14 @@ var ChatClient = class {
575
775
  requestId
576
776
  });
577
777
  }
578
- return response.json();
778
+ try {
779
+ const body = options.readBody ? await options.readBody(response) : await response.json();
780
+ assertRequest();
781
+ return body;
782
+ } catch (error) {
783
+ assertRequest();
784
+ throw error;
785
+ }
579
786
  }
580
787
  /**
581
788
  * Look up the team UUID from the configured `teamSlug`. Used by
@@ -584,37 +791,41 @@ var ChatClient = class {
584
791
  *
585
792
  * @internal
586
793
  */
587
- async resolveTeamIdFromSlug() {
794
+ async resolveTeamIdFromSlug(signal) {
588
795
  const response = await this.fetch(
589
796
  "/api/widget/team-by-slug",
590
797
  {
591
798
  method: "GET",
799
+ signal,
592
800
  query: { teamSlug: this.teamSlug }
593
801
  }
594
802
  );
595
803
  return response.data.teamId;
596
804
  }
597
805
  /** @internal Bootstrap realtime from a session login (no precomputed customerId). */
598
- async initRealtimeFromSession(customerName) {
806
+ async initRealtimeFromSession(customerName, generation) {
807
+ const manager = new RealtimeManager({ fetchToken: () => this.fetchRealtimeToken(generation) });
808
+ this.realtimeManager = manager;
599
809
  try {
600
- this.realtimeManager = new RealtimeManager({
601
- fetchToken: () => this.fetchRealtimeToken()
602
- });
603
- const ready = await this.realtimeManager.init();
810
+ const ready = await manager.init();
811
+ if (generation !== this.sessionGeneration || this.realtimeManager !== manager) {
812
+ manager.destroy();
813
+ return;
814
+ }
604
815
  if (!ready) return;
605
816
  this.realtimeReady = true;
606
- const token = this.realtimeManager.getCurrentToken();
817
+ const token = manager.getCurrentToken();
607
818
  const customerId = token ? decodeJwtSub(token) : null;
608
819
  if (customerId) {
609
820
  this.customerId = customerId;
610
821
  this.presenceManager = new PresenceManager({ customerId, customerName });
611
- const client = this.realtimeManager.getClient();
822
+ const client = manager.getClient();
612
823
  if (client) {
613
824
  this.presenceManager.setClient(client);
614
825
  }
615
826
  }
616
827
  } catch {
617
- this.realtimeReady = false;
828
+ if (generation === this.sessionGeneration) this.realtimeReady = false;
618
829
  }
619
830
  }
620
831
  /** @internal Get the stored team ID for requests that need it. */
@@ -623,23 +834,23 @@ var ChatClient = class {
623
834
  }
624
835
  /**
625
836
  * Resolve a team ID, lazily looking it up from `teamSlug` if no team ID
626
- * has been cached yet. Used by `tickets.list` and friends so that a
627
- * caller who constructed `new ChatClient({ teamSlug, publishableKey })`
628
- * doesn't have to manually call `loginWithCredentials` before reading
629
- * public data.
630
- *
631
- * Closes NEW-v3-05 — `TicketsState(chatClient)` previously errored
632
- * `"Missing teamId"` when the chat client was teamSlug-only because the
633
- * tickets sub-resource read `getTeamId()` synchronously and bailed.
837
+ * has been cached yet. Authenticated resources and realtime bootstrap
838
+ * use this after session login so callers do not need to list tickets
839
+ * or set a UUID before opening an existing conversation.
634
840
  */
635
- async ensureTeamId() {
841
+ async ensureTeamId(signal) {
842
+ const assertCurrent = this.captureSession();
843
+ signal?.throwIfAborted();
636
844
  if (this.teamId) return this.teamId;
637
845
  if (!this.teamSlug) {
638
846
  throw new ChatClientAuthError(
639
847
  "Missing teamId. Construct ChatClient with `teamSlug` or call `setTeamId()` / `identify()` / `loginWithCredentials()` first."
640
848
  );
641
849
  }
642
- this.teamId = await this.resolveTeamIdFromSlug();
850
+ const teamId = await this.resolveTeamIdFromSlug(signal);
851
+ assertCurrent();
852
+ signal?.throwIfAborted();
853
+ this.teamId = teamId;
643
854
  return this.teamId;
644
855
  }
645
856
  /** @internal Set team ID from identify response or other source. */
@@ -652,63 +863,116 @@ var ChatClient = class {
652
863
  await this.realtimeInitPromise;
653
864
  }
654
865
  }
655
- async initRealtime(customerId, customerName) {
866
+ async initRealtime(customerId, customerName, generation) {
867
+ const manager = new RealtimeManager({ fetchToken: () => this.fetchRealtimeToken(generation) });
868
+ this.realtimeManager = manager;
656
869
  try {
657
- this.realtimeManager = new RealtimeManager({
658
- fetchToken: () => this.fetchRealtimeToken()
659
- });
660
- const ready = await this.realtimeManager.init();
870
+ const ready = await manager.init();
871
+ if (generation !== this.sessionGeneration || this.realtimeManager !== manager) {
872
+ manager.destroy();
873
+ return;
874
+ }
661
875
  if (ready) {
662
876
  this.realtimeReady = true;
663
877
  this.presenceManager = new PresenceManager({
664
878
  customerId,
665
879
  customerName
666
880
  });
667
- const client = this.realtimeManager.getClient();
881
+ const client = manager.getClient();
668
882
  if (client) {
669
883
  this.presenceManager.setClient(client);
670
884
  }
671
885
  }
672
886
  } catch {
673
- this.realtimeReady = false;
887
+ if (generation === this.sessionGeneration) this.realtimeReady = false;
674
888
  }
675
889
  }
676
- async fetchRealtimeToken() {
890
+ async fetchRealtimeToken(generation) {
891
+ this.assertSession(generation);
892
+ const teamId = await this.ensureTeamId();
893
+ this.assertSession(generation);
677
894
  const response = await this.fetch(
678
895
  "/api/widget/realtime-token",
679
896
  {
680
897
  method: "POST",
681
- body: { teamId: this.teamId }
898
+ body: { teamId }
682
899
  }
683
900
  );
901
+ this.assertSession(generation);
684
902
  return response.data;
685
903
  }
686
- pollMessages(ticketId, callback) {
687
- let lastMessageId = null;
688
- const poll = async () => {
904
+ pollMessages(ticketId, callback, manager) {
905
+ const seen = /* @__PURE__ */ new Set();
906
+ let initialized = false;
907
+ let active = true;
908
+ let inFlight = false;
909
+ let catchUpQueued = false;
910
+ let connected = false;
911
+ let timer;
912
+ let stopRealtime;
913
+ const deliver = (message) => {
914
+ if (!active || seen.has(message.id)) return;
915
+ seen.add(message.id);
916
+ callback(message);
917
+ };
918
+ const stopPolling = () => {
919
+ clearInterval(timer);
920
+ timer = void 0;
921
+ };
922
+ const startPolling = () => {
923
+ if (active && timer === void 0) {
924
+ timer = setInterval(() => void poll(), this.pollingInterval);
925
+ }
926
+ };
927
+ const poll = async (catchUp = false) => {
928
+ if (!active) return;
929
+ if (inFlight) {
930
+ if (catchUp) catchUpQueued = true;
931
+ return;
932
+ }
933
+ inFlight = true;
689
934
  try {
690
- const messages = await this.messages.list(ticketId);
691
- if (messages.length > 0) {
692
- const latest = messages[messages.length - 1];
693
- if (lastMessageId && latest.id !== lastMessageId) {
694
- const lastIdx = messages.findIndex((m) => m.id === lastMessageId);
695
- const newMessages = lastIdx >= 0 ? messages.slice(lastIdx + 1) : [latest];
696
- for (const msg of newMessages) {
697
- callback(msg);
935
+ do {
936
+ catchUpQueued = false;
937
+ try {
938
+ const messages = await this.messages.list(ticketId);
939
+ if (!active) return;
940
+ for (const message of messages) {
941
+ if (!active) return;
942
+ if (initialized) deliver(message);
943
+ else seen.add(message.id);
698
944
  }
945
+ initialized = true;
946
+ if (connected) stopPolling();
947
+ } catch {
948
+ startPolling();
699
949
  }
700
- lastMessageId = latest.id;
701
- }
702
- } catch {
950
+ } while (active && catchUpQueued);
951
+ } finally {
952
+ inFlight = false;
703
953
  }
704
954
  };
705
- poll();
706
- const timer = setInterval(poll, this.pollingInterval);
707
- this.pollingTimers.set(ticketId, timer);
708
- return () => {
709
- clearInterval(timer);
710
- this.pollingTimers.delete(ticketId);
955
+ const unsubscribe = () => {
956
+ active = false;
957
+ stopPolling();
958
+ stopRealtime?.();
959
+ this.pollingSubscriptions.delete(unsubscribe);
711
960
  };
961
+ this.pollingSubscriptions.add(unsubscribe);
962
+ void poll();
963
+ startPolling();
964
+ stopRealtime = manager?.subscribeToMessages(ticketId, deliver, (status) => {
965
+ if (!active) return;
966
+ if (status === "SUBSCRIBED") {
967
+ connected = true;
968
+ stopPolling();
969
+ } else if (["CHANNEL_ERROR", "TIMED_OUT", "CLOSED"].includes(status)) {
970
+ connected = false;
971
+ startPolling();
972
+ } else return;
973
+ void poll(true);
974
+ });
975
+ return unsubscribe;
712
976
  }
713
977
  buildUrl(path, query) {
714
978
  const base = `${this.baseUrl}${path}`;
@@ -727,10 +991,12 @@ var TicketsApi = class {
727
991
  }
728
992
  /** List tickets for the identified customer. */
729
993
  async list(params) {
994
+ const assertCurrent = this.chat.captureSession();
730
995
  if (this.chat.getAuthMode() === null) {
731
996
  throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
732
997
  }
733
998
  const teamId = await this.chat.ensureTeamId();
999
+ assertCurrent();
734
1000
  const response = await this.chat.fetch("/api/widget/conversations", {
735
1001
  method: "GET",
736
1002
  query: {
@@ -739,6 +1005,7 @@ var TicketsApi = class {
739
1005
  cursor: params?.cursor
740
1006
  }
741
1007
  });
1008
+ assertCurrent();
742
1009
  const data = response.data.tickets ?? response.data.conversations ?? [];
743
1010
  const cursor = response.data.cursor;
744
1011
  const hasMore = response.data.hasMore;
@@ -750,10 +1017,12 @@ var TicketsApi = class {
750
1017
  }
751
1018
  /** Create a new ticket. */
752
1019
  async create(params) {
1020
+ const assertCurrent = this.chat.captureSession();
753
1021
  if (this.chat.getAuthMode() === null) {
754
1022
  throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
755
1023
  }
756
1024
  const teamId = await this.chat.ensureTeamId();
1025
+ assertCurrent();
757
1026
  const response = await this.chat.fetch("/api/widget/tickets", {
758
1027
  method: "POST",
759
1028
  body: {
@@ -762,6 +1031,7 @@ var TicketsApi = class {
762
1031
  message: params.message
763
1032
  }
764
1033
  });
1034
+ assertCurrent();
765
1035
  const messages = (response.data.messages ?? []).map((m) => ({
766
1036
  id: m.id,
767
1037
  content: m.content,
@@ -790,8 +1060,12 @@ var ChatCommunityApi = class {
790
1060
  }
791
1061
  /** Create a new community post as the identified customer. */
792
1062
  async createPost(params) {
793
- const teamId = this.chat.getTeamId();
794
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1063
+ const assertCurrent = this.chat.captureSession();
1064
+ if (this.chat.getAuthMode() === null) {
1065
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1066
+ }
1067
+ const teamId = await this.chat.ensureTeamId();
1068
+ assertCurrent();
795
1069
  const response = await this.chat.fetch("/api/widget/community", {
796
1070
  method: "POST",
797
1071
  body: {
@@ -803,12 +1077,17 @@ var ChatCommunityApi = class {
803
1077
  customFields: params.customFields
804
1078
  }
805
1079
  });
1080
+ assertCurrent();
806
1081
  return response.data.post;
807
1082
  }
808
1083
  /** Update a post. Only the original author can edit. */
809
1084
  async updatePost(postId, params) {
810
- const teamId = this.chat.getTeamId();
811
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1085
+ const assertCurrent = this.chat.captureSession();
1086
+ if (this.chat.getAuthMode() === null) {
1087
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1088
+ }
1089
+ const teamId = await this.chat.ensureTeamId();
1090
+ assertCurrent();
812
1091
  const response = await this.chat.fetch("/api/widget/community", {
813
1092
  method: "POST",
814
1093
  body: {
@@ -820,21 +1099,31 @@ var ChatCommunityApi = class {
820
1099
  customFields: params.customFields
821
1100
  }
822
1101
  });
1102
+ assertCurrent();
823
1103
  return response.data.post;
824
1104
  }
825
1105
  /** Delete a post (soft delete). Only the original author can delete. */
826
1106
  async deletePost(postId) {
827
- const teamId = this.chat.getTeamId();
828
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1107
+ const assertCurrent = this.chat.captureSession();
1108
+ if (this.chat.getAuthMode() === null) {
1109
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1110
+ }
1111
+ const teamId = await this.chat.ensureTeamId();
1112
+ assertCurrent();
829
1113
  await this.chat.fetch("/api/widget/community", {
830
1114
  method: "POST",
831
1115
  body: { teamId, action: "deletePost", postId }
832
1116
  });
1117
+ assertCurrent();
833
1118
  }
834
1119
  /** Update a comment. Only the original author can edit. */
835
1120
  async updateComment(postId, commentId, params) {
836
- const teamId = this.chat.getTeamId();
837
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1121
+ const assertCurrent = this.chat.captureSession();
1122
+ if (this.chat.getAuthMode() === null) {
1123
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1124
+ }
1125
+ const teamId = await this.chat.ensureTeamId();
1126
+ assertCurrent();
838
1127
  const response = await this.chat.fetch("/api/widget/community", {
839
1128
  method: "POST",
840
1129
  body: {
@@ -845,31 +1134,46 @@ var ChatCommunityApi = class {
845
1134
  commentBody: params.body
846
1135
  }
847
1136
  });
1137
+ assertCurrent();
848
1138
  return response.data.comment;
849
1139
  }
850
1140
  /** Delete a comment (soft delete). Only the original author can delete. */
851
1141
  async deleteComment(postId, commentId) {
852
- const teamId = this.chat.getTeamId();
853
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1142
+ const assertCurrent = this.chat.captureSession();
1143
+ if (this.chat.getAuthMode() === null) {
1144
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1145
+ }
1146
+ const teamId = await this.chat.ensureTeamId();
1147
+ assertCurrent();
854
1148
  await this.chat.fetch("/api/widget/community", {
855
1149
  method: "POST",
856
1150
  body: { teamId, action: "deleteComment", postId, commentId }
857
1151
  });
1152
+ assertCurrent();
858
1153
  }
859
1154
  /** Toggle vote on a post. Returns new vote state and count. */
860
1155
  async vote(postId) {
861
- const teamId = this.chat.getTeamId();
862
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1156
+ const assertCurrent = this.chat.captureSession();
1157
+ if (this.chat.getAuthMode() === null) {
1158
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1159
+ }
1160
+ const teamId = await this.chat.ensureTeamId();
1161
+ assertCurrent();
863
1162
  const response = await this.chat.fetch("/api/widget/community", {
864
1163
  method: "POST",
865
1164
  body: { teamId, action: "vote", postId }
866
1165
  });
1166
+ assertCurrent();
867
1167
  return response.data;
868
1168
  }
869
1169
  /** Add a comment to a post. Supports 1-level nesting via parentCommentId. */
870
1170
  async comment(postId, params) {
871
- const teamId = this.chat.getTeamId();
872
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1171
+ const assertCurrent = this.chat.captureSession();
1172
+ if (this.chat.getAuthMode() === null) {
1173
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1174
+ }
1175
+ const teamId = await this.chat.ensureTeamId();
1176
+ assertCurrent();
873
1177
  const response = await this.chat.fetch("/api/widget/community", {
874
1178
  method: "POST",
875
1179
  body: {
@@ -880,37 +1184,53 @@ var ChatCommunityApi = class {
880
1184
  parentCommentId: params.parentCommentId ?? null
881
1185
  }
882
1186
  });
1187
+ assertCurrent();
883
1188
  return response.data.comment;
884
1189
  }
885
1190
  /** Toggle follow on a post. */
886
1191
  async follow(postId) {
887
- const teamId = this.chat.getTeamId();
888
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1192
+ const assertCurrent = this.chat.captureSession();
1193
+ if (this.chat.getAuthMode() === null) {
1194
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1195
+ }
1196
+ const teamId = await this.chat.ensureTeamId();
1197
+ assertCurrent();
889
1198
  const response = await this.chat.fetch("/api/widget/community", {
890
1199
  method: "POST",
891
1200
  body: { teamId, action: "follow", postId }
892
1201
  });
1202
+ assertCurrent();
893
1203
  return response.data;
894
1204
  }
895
1205
  /** Check if the current user has voted on a post. */
896
1206
  async getVoteStatus(postId) {
897
- const teamId = this.chat.getTeamId();
898
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1207
+ const assertCurrent = this.chat.captureSession();
1208
+ if (this.chat.getAuthMode() === null) {
1209
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1210
+ }
1211
+ const teamId = await this.chat.ensureTeamId();
1212
+ assertCurrent();
899
1213
  const response = await this.chat.fetch("/api/widget/community", {
900
1214
  method: "GET",
901
1215
  query: { teamId, postId }
902
1216
  });
1217
+ assertCurrent();
903
1218
  return response.data.voted;
904
1219
  }
905
1220
  /** Check vote status for multiple posts in one request. */
906
1221
  async bulkVoteStatus(postIds) {
907
1222
  if (postIds.length === 0) return {};
908
- const teamId = this.chat.getTeamId();
909
- if (!teamId) throw new ChatClientAuthError("Not identified. Call chat.identify() first.");
1223
+ const assertCurrent = this.chat.captureSession();
1224
+ if (this.chat.getAuthMode() === null) {
1225
+ throw new ChatClientAuthError("Sign in or identify a customer before accessing community.");
1226
+ }
1227
+ const teamId = await this.chat.ensureTeamId();
1228
+ assertCurrent();
910
1229
  const response = await this.chat.fetch("/api/widget/community", {
911
1230
  method: "GET",
912
1231
  query: { teamId, postIds: postIds.join(",") }
913
1232
  });
1233
+ assertCurrent();
914
1234
  return response.data;
915
1235
  }
916
1236
  };
@@ -920,22 +1240,27 @@ var MessagesApi = class {
920
1240
  }
921
1241
  /** List messages for a ticket. */
922
1242
  async list(ticketId) {
923
- const teamId = this.chat.getTeamId();
1243
+ const assertCurrent = this.chat.captureSession();
1244
+ const teamId = await this.chat.ensureTeamId();
1245
+ assertCurrent();
924
1246
  const response = await this.chat.fetch(
925
1247
  "/api/widget/messages",
926
1248
  {
927
1249
  method: "GET",
928
1250
  query: {
929
- teamId: teamId ?? void 0,
1251
+ teamId,
930
1252
  ticketId
931
1253
  }
932
1254
  }
933
1255
  );
1256
+ assertCurrent();
934
1257
  return response.data.messages;
935
1258
  }
936
1259
  /** Send a message to a ticket. */
937
1260
  async send(ticketId, content) {
938
- const teamId = this.chat.getTeamId();
1261
+ const assertCurrent = this.chat.captureSession();
1262
+ const teamId = await this.chat.ensureTeamId();
1263
+ assertCurrent();
939
1264
  const response = await this.chat.fetch(
940
1265
  "/api/widget/messages",
941
1266
  {
@@ -947,6 +1272,7 @@ var MessagesApi = class {
947
1272
  }
948
1273
  }
949
1274
  );
1275
+ assertCurrent();
950
1276
  return response.data.message;
951
1277
  }
952
1278
  };