@cstar.help/js 0.17.0 → 0.17.3

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;
@@ -7,6 +29,7 @@ var RealtimeManager = class {
7
29
  this.refreshTimer = null;
8
30
  this.currentToken = null;
9
31
  this.destroyed = false;
32
+ this.cleanupPromise = null;
10
33
  this.options = options;
11
34
  }
12
35
  /**
@@ -28,7 +51,11 @@ var RealtimeManager = class {
28
51
  "@supabase/supabase-js"
29
52
  );
30
53
  if (this.destroyed) return false;
54
+ this.currentToken = tokenData.token;
31
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,
32
59
  auth: { persistSession: false, autoRefreshToken: false },
33
60
  realtime: { params: { eventsPerSecond: 10 } }
34
61
  });
@@ -69,12 +96,14 @@ var RealtimeManager = class {
69
96
  * Subscribe to new messages on a ticket via Postgres Changes.
70
97
  * Returns an unsubscribe function.
71
98
  */
72
- subscribeToMessages(ticketId, callback) {
99
+ subscribeToMessages(ticketId, callback, onStatus) {
73
100
  if (!this.client) return () => {
74
101
  };
75
102
  const channelName = `messages:${ticketId}`;
76
103
  this.removeChannel(channelName);
77
- const channel = this.client.channel(channelName).on(
104
+ const channel = this.client.channel(channelName);
105
+ this.channels.set(channelName, channel);
106
+ channel.on(
78
107
  "postgres_changes",
79
108
  {
80
109
  event: "INSERT",
@@ -83,6 +112,7 @@ var RealtimeManager = class {
83
112
  filter: `ticket_id=eq.${ticketId}`
84
113
  },
85
114
  (payload) => {
115
+ if (this.channels.get(channelName) !== channel) return;
86
116
  const row = payload.new;
87
117
  if (!row) return;
88
118
  const message = {
@@ -97,9 +127,10 @@ var RealtimeManager = class {
97
127
  };
98
128
  callback(message);
99
129
  }
100
- ).subscribe();
101
- this.channels.set(channelName, channel);
102
- return () => this.removeChannel(channelName);
130
+ ).subscribe((status) => {
131
+ if (this.channels.get(channelName) === channel) onStatus?.(status);
132
+ });
133
+ return () => this.removeChannel(channelName, channel);
103
134
  }
104
135
  /** Get the underlying realtime client (for sharing with PresenceManager). */
105
136
  getClient() {
@@ -117,15 +148,17 @@ var RealtimeManager = class {
117
148
  get isReady() {
118
149
  return this.client !== null && this.currentToken !== null;
119
150
  }
120
- removeChannel(name) {
151
+ removeChannel(name, expected) {
121
152
  const existing = this.channels.get(name);
153
+ if (expected && existing !== expected) return;
122
154
  if (existing && this.client) {
123
- this.client.removeChannel(existing);
124
155
  this.channels.delete(name);
156
+ removeRealtimeChannel(this.client, existing);
125
157
  }
126
158
  }
127
159
  /** Clean up all channels, timers, and the client. */
128
160
  destroy() {
161
+ if (this.destroyed) return this.cleanupPromise ?? Promise.resolve();
129
162
  this.destroyed = true;
130
163
  if (this.refreshTimer) {
131
164
  clearTimeout(this.refreshTimer);
@@ -135,9 +168,13 @@ var RealtimeManager = class {
135
168
  this.removeChannel(name);
136
169
  }
137
170
  this.channels.clear();
171
+ const client = this.client;
138
172
  this.client = null;
139
173
  this.initPromise = null;
140
174
  this.currentToken = null;
175
+ this.cleanupPromise = client ? closeRealtimeClient(client).catch(() => {
176
+ }) : Promise.resolve();
177
+ return this.cleanupPromise;
141
178
  }
142
179
  };
143
180
 
@@ -167,7 +204,10 @@ var PresenceManager = class {
167
204
  };
168
205
  const channelName = `typing:${ticketId}`;
169
206
  this.removeChannel(channelName);
170
- 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;
171
211
  const payload = data?.payload;
172
212
  if (!payload) return;
173
213
  if (payload.agent_id) {
@@ -179,8 +219,7 @@ var PresenceManager = class {
179
219
  callback(typingPayload);
180
220
  }
181
221
  }).subscribe();
182
- this.channels.set(channelName, channel);
183
- return () => this.removeChannel(channelName);
222
+ return () => this.removeChannel(channelName, channel);
184
223
  }
185
224
  /**
186
225
  * Send a customer typing indicator.
@@ -233,11 +272,12 @@ var PresenceManager = class {
233
272
  } catch {
234
273
  }
235
274
  }
236
- removeChannel(name) {
275
+ removeChannel(name, expected) {
237
276
  const existing = this.channels.get(name);
277
+ if (expected && existing !== expected) return;
238
278
  if (existing && this.client) {
239
- this.client.removeChannel(existing);
240
279
  this.channels.delete(name);
280
+ removeRealtimeChannel(this.client, existing);
241
281
  }
242
282
  }
243
283
  /** Clean up all channels and timers. */
@@ -399,6 +439,7 @@ var ChatClient = class {
399
439
  this.authMode = null;
400
440
  this.sessionGeneration = 0;
401
441
  this.pollingSubscriptions = /* @__PURE__ */ new Set();
442
+ this.realtimeMessageSubscriptions = /* @__PURE__ */ new Map();
402
443
  this.stateListeners = /* @__PURE__ */ new Set();
403
444
  this.realtimeManager = null;
404
445
  this.presenceManager = null;
@@ -564,11 +605,16 @@ var ChatClient = class {
564
605
  * @returns Unsubscribe function
565
606
  */
566
607
  onMessage(ticketId, callback) {
567
- const generation = this.sessionGeneration;
568
608
  if (this.realtimeReady && this.realtimeManager) {
569
- return this.realtimeManager.subscribeToMessages(ticketId, (message) => {
570
- if (generation === this.sessionGeneration) callback(message);
571
- });
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
+ };
572
618
  }
573
619
  return this.pollMessages(ticketId, callback);
574
620
  }
@@ -603,20 +649,21 @@ var ChatClient = class {
603
649
  }
604
650
  retireSession() {
605
651
  const generation = ++this.sessionGeneration;
606
- if (this.realtimeManager) {
607
- this.realtimeManager.destroy();
608
- this.realtimeManager = null;
609
- }
610
652
  if (this.presenceManager) {
611
653
  this.presenceManager.destroy();
612
654
  this.presenceManager = null;
613
655
  }
656
+ if (this.realtimeManager) {
657
+ this.realtimeManager.destroy();
658
+ this.realtimeManager = null;
659
+ }
614
660
  this.realtimeReady = false;
615
661
  this.realtimeInitPromise = null;
616
662
  for (const unsubscribe of this.pollingSubscriptions) {
617
663
  unsubscribe();
618
664
  }
619
665
  this.pollingSubscriptions.clear();
666
+ this.realtimeMessageSubscriptions.clear();
620
667
  this.sessionToken = null;
621
668
  this.teamId = null;
622
669
  this.customerId = null;
@@ -787,14 +834,9 @@ var ChatClient = class {
787
834
  }
788
835
  /**
789
836
  * Resolve a team ID, lazily looking it up from `teamSlug` if no team ID
790
- * has been cached yet. Used by `tickets.list` and friends so that a
791
- * caller who constructed `new ChatClient({ teamSlug, publishableKey })`
792
- * doesn't have to manually call `loginWithCredentials` before reading
793
- * public data.
794
- *
795
- * Closes NEW-v3-05 — `TicketsState(chatClient)` previously errored
796
- * `"Missing teamId"` when the chat client was teamSlug-only because the
797
- * 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.
798
840
  */
799
841
  async ensureTeamId(signal) {
800
842
  const assertCurrent = this.captureSession();
@@ -846,54 +888,90 @@ var ChatClient = class {
846
888
  }
847
889
  }
848
890
  async fetchRealtimeToken(generation) {
891
+ this.assertSession(generation);
892
+ const teamId = await this.ensureTeamId();
849
893
  this.assertSession(generation);
850
894
  const response = await this.fetch(
851
895
  "/api/widget/realtime-token",
852
896
  {
853
897
  method: "POST",
854
- body: { teamId: this.teamId }
898
+ body: { teamId }
855
899
  }
856
900
  );
857
901
  this.assertSession(generation);
858
902
  return response.data;
859
903
  }
860
- pollMessages(ticketId, callback) {
861
- let lastMessageId = null;
904
+ pollMessages(ticketId, callback, manager) {
905
+ const seen = /* @__PURE__ */ new Set();
862
906
  let initialized = false;
863
907
  let active = true;
864
908
  let inFlight = false;
865
- const poll = async () => {
866
- if (!active || inFlight) return;
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
+ }
867
933
  inFlight = true;
868
934
  try {
869
- const messages = await this.messages.list(ticketId);
870
- if (!active) return;
871
- if (messages.length > 0) {
872
- const latest = messages[messages.length - 1];
873
- if (initialized && latest.id !== lastMessageId) {
874
- const lastIdx = messages.findIndex((m) => m.id === lastMessageId);
875
- const newMessages = lastIdx >= 0 ? messages.slice(lastIdx + 1) : lastMessageId ? [latest] : messages;
876
- for (const msg of newMessages) {
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) {
877
941
  if (!active) return;
878
- callback(msg);
942
+ if (initialized) deliver(message);
943
+ else seen.add(message.id);
879
944
  }
945
+ initialized = true;
946
+ if (connected) stopPolling();
947
+ } catch {
948
+ startPolling();
880
949
  }
881
- lastMessageId = latest.id;
882
- }
883
- initialized = true;
884
- } catch {
950
+ } while (active && catchUpQueued);
885
951
  } finally {
886
952
  inFlight = false;
887
953
  }
888
954
  };
889
- poll();
890
- const timer = setInterval(poll, this.pollingInterval);
891
955
  const unsubscribe = () => {
892
956
  active = false;
893
- clearInterval(timer);
957
+ stopPolling();
958
+ stopRealtime?.();
894
959
  this.pollingSubscriptions.delete(unsubscribe);
895
960
  };
896
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
+ });
897
975
  return unsubscribe;
898
976
  }
899
977
  buildUrl(path, query) {
@@ -982,8 +1060,12 @@ var ChatCommunityApi = class {
982
1060
  }
983
1061
  /** Create a new community post as the identified customer. */
984
1062
  async createPost(params) {
985
- const teamId = this.chat.getTeamId();
986
- 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();
987
1069
  const response = await this.chat.fetch("/api/widget/community", {
988
1070
  method: "POST",
989
1071
  body: {
@@ -995,12 +1077,17 @@ var ChatCommunityApi = class {
995
1077
  customFields: params.customFields
996
1078
  }
997
1079
  });
1080
+ assertCurrent();
998
1081
  return response.data.post;
999
1082
  }
1000
1083
  /** Update a post. Only the original author can edit. */
1001
1084
  async updatePost(postId, params) {
1002
- const teamId = this.chat.getTeamId();
1003
- 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();
1004
1091
  const response = await this.chat.fetch("/api/widget/community", {
1005
1092
  method: "POST",
1006
1093
  body: {
@@ -1012,21 +1099,31 @@ var ChatCommunityApi = class {
1012
1099
  customFields: params.customFields
1013
1100
  }
1014
1101
  });
1102
+ assertCurrent();
1015
1103
  return response.data.post;
1016
1104
  }
1017
1105
  /** Delete a post (soft delete). Only the original author can delete. */
1018
1106
  async deletePost(postId) {
1019
- const teamId = this.chat.getTeamId();
1020
- 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();
1021
1113
  await this.chat.fetch("/api/widget/community", {
1022
1114
  method: "POST",
1023
1115
  body: { teamId, action: "deletePost", postId }
1024
1116
  });
1117
+ assertCurrent();
1025
1118
  }
1026
1119
  /** Update a comment. Only the original author can edit. */
1027
1120
  async updateComment(postId, commentId, params) {
1028
- const teamId = this.chat.getTeamId();
1029
- 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();
1030
1127
  const response = await this.chat.fetch("/api/widget/community", {
1031
1128
  method: "POST",
1032
1129
  body: {
@@ -1037,31 +1134,46 @@ var ChatCommunityApi = class {
1037
1134
  commentBody: params.body
1038
1135
  }
1039
1136
  });
1137
+ assertCurrent();
1040
1138
  return response.data.comment;
1041
1139
  }
1042
1140
  /** Delete a comment (soft delete). Only the original author can delete. */
1043
1141
  async deleteComment(postId, commentId) {
1044
- const teamId = this.chat.getTeamId();
1045
- 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();
1046
1148
  await this.chat.fetch("/api/widget/community", {
1047
1149
  method: "POST",
1048
1150
  body: { teamId, action: "deleteComment", postId, commentId }
1049
1151
  });
1152
+ assertCurrent();
1050
1153
  }
1051
1154
  /** Toggle vote on a post. Returns new vote state and count. */
1052
1155
  async vote(postId) {
1053
- const teamId = this.chat.getTeamId();
1054
- 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();
1055
1162
  const response = await this.chat.fetch("/api/widget/community", {
1056
1163
  method: "POST",
1057
1164
  body: { teamId, action: "vote", postId }
1058
1165
  });
1166
+ assertCurrent();
1059
1167
  return response.data;
1060
1168
  }
1061
1169
  /** Add a comment to a post. Supports 1-level nesting via parentCommentId. */
1062
1170
  async comment(postId, params) {
1063
- const teamId = this.chat.getTeamId();
1064
- 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();
1065
1177
  const response = await this.chat.fetch("/api/widget/community", {
1066
1178
  method: "POST",
1067
1179
  body: {
@@ -1072,37 +1184,53 @@ var ChatCommunityApi = class {
1072
1184
  parentCommentId: params.parentCommentId ?? null
1073
1185
  }
1074
1186
  });
1187
+ assertCurrent();
1075
1188
  return response.data.comment;
1076
1189
  }
1077
1190
  /** Toggle follow on a post. */
1078
1191
  async follow(postId) {
1079
- const teamId = this.chat.getTeamId();
1080
- 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();
1081
1198
  const response = await this.chat.fetch("/api/widget/community", {
1082
1199
  method: "POST",
1083
1200
  body: { teamId, action: "follow", postId }
1084
1201
  });
1202
+ assertCurrent();
1085
1203
  return response.data;
1086
1204
  }
1087
1205
  /** Check if the current user has voted on a post. */
1088
1206
  async getVoteStatus(postId) {
1089
- const teamId = this.chat.getTeamId();
1090
- 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();
1091
1213
  const response = await this.chat.fetch("/api/widget/community", {
1092
1214
  method: "GET",
1093
1215
  query: { teamId, postId }
1094
1216
  });
1217
+ assertCurrent();
1095
1218
  return response.data.voted;
1096
1219
  }
1097
1220
  /** Check vote status for multiple posts in one request. */
1098
1221
  async bulkVoteStatus(postIds) {
1099
1222
  if (postIds.length === 0) return {};
1100
- const teamId = this.chat.getTeamId();
1101
- 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();
1102
1229
  const response = await this.chat.fetch("/api/widget/community", {
1103
1230
  method: "GET",
1104
1231
  query: { teamId, postIds: postIds.join(",") }
1105
1232
  });
1233
+ assertCurrent();
1106
1234
  return response.data;
1107
1235
  }
1108
1236
  };
@@ -1113,13 +1241,14 @@ var MessagesApi = class {
1113
1241
  /** List messages for a ticket. */
1114
1242
  async list(ticketId) {
1115
1243
  const assertCurrent = this.chat.captureSession();
1116
- const teamId = this.chat.getTeamId();
1244
+ const teamId = await this.chat.ensureTeamId();
1245
+ assertCurrent();
1117
1246
  const response = await this.chat.fetch(
1118
1247
  "/api/widget/messages",
1119
1248
  {
1120
1249
  method: "GET",
1121
1250
  query: {
1122
- teamId: teamId ?? void 0,
1251
+ teamId,
1123
1252
  ticketId
1124
1253
  }
1125
1254
  }
@@ -1130,7 +1259,8 @@ var MessagesApi = class {
1130
1259
  /** Send a message to a ticket. */
1131
1260
  async send(ticketId, content) {
1132
1261
  const assertCurrent = this.chat.captureSession();
1133
- const teamId = this.chat.getTeamId();
1262
+ const teamId = await this.chat.ensureTeamId();
1263
+ assertCurrent();
1134
1264
  const response = await this.chat.fetch(
1135
1265
  "/api/widget/messages",
1136
1266
  {