@nurama/sdk 0.0.0-stage → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +5 -0
  3. package/README.md +1080 -2
  4. package/dist/BotClient.d.ts +66 -0
  5. package/dist/BotClient.d.ts.map +1 -0
  6. package/dist/BotClient.js +68 -0
  7. package/dist/BotClient.js.map +1 -0
  8. package/dist/NuramaClient.d.ts +480 -0
  9. package/dist/NuramaClient.d.ts.map +1 -0
  10. package/dist/NuramaClient.js +902 -0
  11. package/dist/NuramaClient.js.map +1 -0
  12. package/dist/browser/nurama-bot-sdk.js +12051 -0
  13. package/dist/browser/nurama-bot-sdk.min.js +1 -0
  14. package/dist/browser/nurama-sdk.js +12003 -0
  15. package/dist/browser/nurama-sdk.min.js +1 -0
  16. package/dist/routes/ai.d.ts +280 -0
  17. package/dist/routes/ai.d.ts.map +1 -0
  18. package/dist/routes/ai.js +173 -0
  19. package/dist/routes/ai.js.map +1 -0
  20. package/dist/routes/asset.d.ts +493 -0
  21. package/dist/routes/asset.d.ts.map +1 -0
  22. package/dist/routes/asset.js +848 -0
  23. package/dist/routes/asset.js.map +1 -0
  24. package/dist/routes/auth.d.ts +218 -0
  25. package/dist/routes/auth.d.ts.map +1 -0
  26. package/dist/routes/auth.js +454 -0
  27. package/dist/routes/auth.js.map +1 -0
  28. package/dist/routes/blogPosts.d.ts +17 -0
  29. package/dist/routes/blogPosts.d.ts.map +1 -0
  30. package/dist/routes/blogPosts.js +29 -0
  31. package/dist/routes/blogPosts.js.map +1 -0
  32. package/dist/routes/board.d.ts +187 -0
  33. package/dist/routes/board.d.ts.map +1 -0
  34. package/dist/routes/board.js +270 -0
  35. package/dist/routes/board.js.map +1 -0
  36. package/dist/routes/bot.d.ts +202 -0
  37. package/dist/routes/bot.d.ts.map +1 -0
  38. package/dist/routes/bot.js +229 -0
  39. package/dist/routes/bot.js.map +1 -0
  40. package/dist/routes/chat.d.ts +842 -0
  41. package/dist/routes/chat.d.ts.map +1 -0
  42. package/dist/routes/chat.js +863 -0
  43. package/dist/routes/chat.js.map +1 -0
  44. package/dist/routes/chatAi.d.ts +51 -0
  45. package/dist/routes/chatAi.d.ts.map +1 -0
  46. package/dist/routes/chatAi.js +109 -0
  47. package/dist/routes/chatAi.js.map +1 -0
  48. package/dist/routes/config.d.ts +11 -0
  49. package/dist/routes/config.d.ts.map +1 -0
  50. package/dist/routes/config.js +24 -0
  51. package/dist/routes/config.js.map +1 -0
  52. package/dist/routes/convo.d.ts +169 -0
  53. package/dist/routes/convo.d.ts.map +1 -0
  54. package/dist/routes/convo.js +284 -0
  55. package/dist/routes/convo.js.map +1 -0
  56. package/dist/routes/credits.d.ts +82 -0
  57. package/dist/routes/credits.d.ts.map +1 -0
  58. package/dist/routes/credits.js +49 -0
  59. package/dist/routes/credits.js.map +1 -0
  60. package/dist/routes/device.d.ts +74 -0
  61. package/dist/routes/device.d.ts.map +1 -0
  62. package/dist/routes/device.js +122 -0
  63. package/dist/routes/device.js.map +1 -0
  64. package/dist/routes/folder.d.ts +75 -0
  65. package/dist/routes/folder.d.ts.map +1 -0
  66. package/dist/routes/folder.js +99 -0
  67. package/dist/routes/folder.js.map +1 -0
  68. package/dist/routes/invite.d.ts +61 -0
  69. package/dist/routes/invite.d.ts.map +1 -0
  70. package/dist/routes/invite.js +86 -0
  71. package/dist/routes/invite.js.map +1 -0
  72. package/dist/routes/joinLink.d.ts +88 -0
  73. package/dist/routes/joinLink.d.ts.map +1 -0
  74. package/dist/routes/joinLink.js +205 -0
  75. package/dist/routes/joinLink.js.map +1 -0
  76. package/dist/routes/membership.d.ts +116 -0
  77. package/dist/routes/membership.d.ts.map +1 -0
  78. package/dist/routes/membership.js +183 -0
  79. package/dist/routes/membership.js.map +1 -0
  80. package/dist/routes/notification.d.ts +103 -0
  81. package/dist/routes/notification.d.ts.map +1 -0
  82. package/dist/routes/notification.js +89 -0
  83. package/dist/routes/notification.js.map +1 -0
  84. package/dist/routes/oauthGrant.d.ts +45 -0
  85. package/dist/routes/oauthGrant.d.ts.map +1 -0
  86. package/dist/routes/oauthGrant.js +32 -0
  87. package/dist/routes/oauthGrant.js.map +1 -0
  88. package/dist/routes/payment.d.ts +56 -0
  89. package/dist/routes/payment.d.ts.map +1 -0
  90. package/dist/routes/payment.js +78 -0
  91. package/dist/routes/payment.js.map +1 -0
  92. package/dist/routes/product.d.ts +43 -0
  93. package/dist/routes/product.d.ts.map +1 -0
  94. package/dist/routes/product.js +53 -0
  95. package/dist/routes/product.js.map +1 -0
  96. package/dist/routes/project.d.ts +821 -0
  97. package/dist/routes/project.d.ts.map +1 -0
  98. package/dist/routes/project.js +1153 -0
  99. package/dist/routes/project.js.map +1 -0
  100. package/dist/routes/public.d.ts +269 -0
  101. package/dist/routes/public.d.ts.map +1 -0
  102. package/dist/routes/public.js +412 -0
  103. package/dist/routes/public.js.map +1 -0
  104. package/dist/routes/scratch.d.ts +70 -0
  105. package/dist/routes/scratch.d.ts.map +1 -0
  106. package/dist/routes/scratch.js +67 -0
  107. package/dist/routes/scratch.js.map +1 -0
  108. package/dist/routes/settings.d.ts +102 -0
  109. package/dist/routes/settings.d.ts.map +1 -0
  110. package/dist/routes/settings.js +94 -0
  111. package/dist/routes/settings.js.map +1 -0
  112. package/dist/routes/shortlink.d.ts +79 -0
  113. package/dist/routes/shortlink.d.ts.map +1 -0
  114. package/dist/routes/shortlink.js +25 -0
  115. package/dist/routes/shortlink.js.map +1 -0
  116. package/dist/routes/socket.d.ts +108 -0
  117. package/dist/routes/socket.d.ts.map +1 -0
  118. package/dist/routes/socket.js +573 -0
  119. package/dist/routes/socket.js.map +1 -0
  120. package/dist/routes/storage.d.ts +44 -0
  121. package/dist/routes/storage.d.ts.map +1 -0
  122. package/dist/routes/storage.js +49 -0
  123. package/dist/routes/storage.js.map +1 -0
  124. package/dist/routes/subscription.d.ts +184 -0
  125. package/dist/routes/subscription.d.ts.map +1 -0
  126. package/dist/routes/subscription.js +219 -0
  127. package/dist/routes/subscription.js.map +1 -0
  128. package/dist/routes/supportChat.d.ts +40 -0
  129. package/dist/routes/supportChat.d.ts.map +1 -0
  130. package/dist/routes/supportChat.js +53 -0
  131. package/dist/routes/supportChat.js.map +1 -0
  132. package/dist/routes/supportTicket.d.ts +89 -0
  133. package/dist/routes/supportTicket.d.ts.map +1 -0
  134. package/dist/routes/supportTicket.js +54 -0
  135. package/dist/routes/supportTicket.js.map +1 -0
  136. package/dist/routes/tag.d.ts +72 -0
  137. package/dist/routes/tag.d.ts.map +1 -0
  138. package/dist/routes/tag.js +81 -0
  139. package/dist/routes/tag.js.map +1 -0
  140. package/dist/routes/task.d.ts +252 -0
  141. package/dist/routes/task.d.ts.map +1 -0
  142. package/dist/routes/task.js +284 -0
  143. package/dist/routes/task.js.map +1 -0
  144. package/dist/routes/taskRelation.d.ts +80 -0
  145. package/dist/routes/taskRelation.d.ts.map +1 -0
  146. package/dist/routes/taskRelation.js +71 -0
  147. package/dist/routes/taskRelation.js.map +1 -0
  148. package/dist/routes/token.d.ts +97 -0
  149. package/dist/routes/token.d.ts.map +1 -0
  150. package/dist/routes/token.js +73 -0
  151. package/dist/routes/token.js.map +1 -0
  152. package/dist/routes/user.d.ts +112 -0
  153. package/dist/routes/user.d.ts.map +1 -0
  154. package/dist/routes/user.js +151 -0
  155. package/dist/routes/user.js.map +1 -0
  156. package/dist/routes/version.d.ts +42 -0
  157. package/dist/routes/version.d.ts.map +1 -0
  158. package/dist/routes/version.js +38 -0
  159. package/dist/routes/version.js.map +1 -0
  160. package/dist/routes/webhook.d.ts +170 -0
  161. package/dist/routes/webhook.d.ts.map +1 -0
  162. package/dist/routes/webhook.js +173 -0
  163. package/dist/routes/webhook.js.map +1 -0
  164. package/dist/routes/workspace.d.ts +120 -0
  165. package/dist/routes/workspace.d.ts.map +1 -0
  166. package/dist/routes/workspace.js +199 -0
  167. package/dist/routes/workspace.js.map +1 -0
  168. package/dist/utils/uploadSessionManager.d.ts +133 -0
  169. package/dist/utils/uploadSessionManager.d.ts.map +1 -0
  170. package/dist/utils/uploadSessionManager.js +321 -0
  171. package/dist/utils/uploadSessionManager.js.map +1 -0
  172. package/dist/utils/urlParams.d.ts +35 -0
  173. package/dist/utils/urlParams.d.ts.map +1 -0
  174. package/dist/utils/urlParams.js +146 -0
  175. package/dist/utils/urlParams.js.map +1 -0
  176. package/dist/version.d.ts +15 -0
  177. package/dist/version.d.ts.map +1 -0
  178. package/dist/version.js +12 -0
  179. package/dist/version.js.map +1 -0
  180. package/package.json +87 -3
  181. package/src/BotClient.ts +113 -0
  182. package/src/NuramaClient.ts +1253 -0
  183. package/src/bot-browser-entry.js +15 -0
  184. package/src/browser-entry.js +20 -0
  185. package/src/routes/ai.ts +378 -0
  186. package/src/routes/asset.ts +1104 -0
  187. package/src/routes/auth.ts +587 -0
  188. package/src/routes/blogPosts.ts +29 -0
  189. package/src/routes/board.ts +403 -0
  190. package/src/routes/bot.ts +356 -0
  191. package/src/routes/chat.ts +1292 -0
  192. package/src/routes/chatAi.ts +125 -0
  193. package/src/routes/config.ts +31 -0
  194. package/src/routes/convo.ts +321 -0
  195. package/src/routes/credits.ts +112 -0
  196. package/src/routes/device.ts +133 -0
  197. package/src/routes/folder.ts +154 -0
  198. package/src/routes/invite.ts +133 -0
  199. package/src/routes/joinLink.ts +233 -0
  200. package/src/routes/membership.ts +237 -0
  201. package/src/routes/notification.ts +166 -0
  202. package/src/routes/oauthGrant.ts +64 -0
  203. package/src/routes/payment.ts +104 -0
  204. package/src/routes/product.ts +67 -0
  205. package/src/routes/project.ts +1528 -0
  206. package/src/routes/public.ts +496 -0
  207. package/src/routes/scratch.ts +94 -0
  208. package/src/routes/settings.ts +152 -0
  209. package/src/routes/shortlink.ts +90 -0
  210. package/src/routes/socket.ts +757 -0
  211. package/src/routes/storage.ts +83 -0
  212. package/src/routes/subscription.ts +307 -0
  213. package/src/routes/supportChat.ts +62 -0
  214. package/src/routes/supportTicket.ts +114 -0
  215. package/src/routes/tag.ts +131 -0
  216. package/src/routes/task.ts +431 -0
  217. package/src/routes/taskRelation.ts +125 -0
  218. package/src/routes/token.ts +152 -0
  219. package/src/routes/user.ts +214 -0
  220. package/src/routes/version.ts +62 -0
  221. package/src/routes/webhook.ts +295 -0
  222. package/src/routes/workspace.ts +223 -0
  223. package/src/utils/uploadSessionManager.ts +407 -0
  224. package/src/utils/urlParams.ts +181 -0
  225. package/src/version.ts +22 -0
@@ -0,0 +1,757 @@
1
+ /* eslint-disable no-console */
2
+
3
+ import NuramaClient from '../NuramaClient.js';
4
+
5
+ // Define a generic Socket interface to avoid tight coupling to the socket.io-client types
6
+ export interface SocketInterface {
7
+ connected: boolean;
8
+ on: (event: string, listener: (...args: any[]) => void) => void;
9
+ off: (event: string) => void;
10
+ disconnect: () => void;
11
+ emit: (event: string, ...args: any[]) => void;
12
+ // socket.io v4 timeout-bounded ack pattern: `.timeout(ms).emit(ev, ...args, cb)`.
13
+ // Returns a thin chainable whose `.emit` invokes `cb(Error | null, ...ackArgs)`
14
+ // — Error on timeout, null on server ack. Used by `emitWithAck`.
15
+ timeout: (ms: number) => { emit: (event: string, ...args: any[]) => void };
16
+ }
17
+
18
+ // Interfaces for socket events
19
+ export interface BaseNotificationEvent {
20
+ type: string;
21
+ initiatorId: string | null;
22
+ initiatorType: 'user' | 'system';
23
+ initiator: any | null; // User object if initiatorType is 'user'
24
+ resourceId: string;
25
+ resourceType: string;
26
+ channels: string[];
27
+ createdAt: string;
28
+ tokens?: Record<string, any>;
29
+ changes?: {
30
+ create?: Array<{ resourceId: string; resourceType: string; resource: any }>;
31
+ update?: Array<{ resourceId: string; resourceType: string; resource: any; oldResource?: any; newResource?: any }>;
32
+ delete?: Array<{ resourceId: string; resourceType: string }>;
33
+ };
34
+ }
35
+
36
+ export interface SocketOptions {
37
+ /**
38
+ * Automatically handle token refresh
39
+ * @default true
40
+ */
41
+ autoRefresh?: boolean;
42
+
43
+ /**
44
+ * Automatically reconnect if connection is lost
45
+ * @default true
46
+ */
47
+ autoReconnect?: boolean;
48
+
49
+ /**
50
+ * Automatically attempt to reconnect if token expires
51
+ * @default true
52
+ */
53
+ autoReconnectOnTokenExpiry?: boolean;
54
+
55
+ /**
56
+ * Debug logging
57
+ * @default false
58
+ */
59
+ debug?: boolean;
60
+
61
+ /**
62
+ * Override the WebSocket URL for this specific connection
63
+ * Takes precedence over the client's websocketURL option
64
+ */
65
+ websocketURL?: string;
66
+ }
67
+
68
+ export interface PublicSocketOptions {
69
+ /**
70
+ * Automatically reconnect if connection is lost
71
+ * @default true
72
+ */
73
+ autoReconnect?: boolean;
74
+
75
+ /**
76
+ * Debug logging
77
+ * @default false
78
+ */
79
+ debug?: boolean;
80
+
81
+ /**
82
+ * Override the WebSocket URL for this specific connection
83
+ * Takes precedence over the client's websocketURL option
84
+ */
85
+ websocketURL?: string;
86
+ }
87
+
88
+ export interface SocketChannel {
89
+ /** Socket.IO instance */
90
+ socket: SocketInterface;
91
+ /** Channel path */
92
+ channel: string;
93
+ /** Event listeners */
94
+ listeners: Record<string, Set<(event: any) => void>>;
95
+ }
96
+
97
+ /**
98
+ * Listener-set keys that the built-in socket handlers bound in connect()/
99
+ * connectPublic() dispatch to directly (rather than via a subscribe()-bound
100
+ * `socket.on`). When re-establishing subscriptions after an in-place reconnect
101
+ * these must be repopulated on the new connection's listener set, NOT re-run
102
+ * through subscribe() — doing so would bind a second `socket.on` dispatcher and
103
+ * fire each callback twice.
104
+ */
105
+ const LIFECYCLE_LISTENER_EVENTS = new Set([
106
+ 'disconnect',
107
+ 'tokenExpired',
108
+ 'tokenBlacklisted',
109
+ 'reconnect_failed',
110
+ 'reconnect',
111
+ ]);
112
+
113
+ /** First reconnect delay; socket.io doubles it per failed attempt, up to RECONNECTION_DELAY_MAX, ±50% jitter. */
114
+ const RECONNECTION_DELAY = 1000;
115
+ const RECONNECTION_DELAY_MAX = 30000;
116
+
117
+ /**
118
+ * Extra first-retry delay, up to this, after the SERVER closed the connection ("transport close"): a ws deploy
119
+ * or restart drops every client of that server at once, and without it they would all reconnect within the
120
+ * same second. Network drops ("ping timeout", "transport error") keep the normal 1s retry.
121
+ */
122
+ const SERVER_CLOSE_RECONNECT_SPREAD = 10000;
123
+
124
+ /**
125
+ * Spread reconnects after a server-side close (see SERVER_CLOSE_RECONNECT_SPREAD). Sets the manager's next
126
+ * delay from the socket's 'disconnect', which socket.io emits before it schedules the retry, and restores the
127
+ * normal delay on 'connect'. Channels share one manager, so the last channel's draw wins, which is fine.
128
+ */
129
+ const spreadReconnectAfterServerClose = (socket: any): void => {
130
+ const manager = socket.io;
131
+ if (typeof manager?.reconnectionDelay !== 'function') return;
132
+ socket.on('disconnect', (reason: string) => {
133
+ if (reason === 'transport close') {
134
+ manager.reconnectionDelay(RECONNECTION_DELAY + Math.random() * SERVER_CLOSE_RECONNECT_SPREAD);
135
+ }
136
+ });
137
+ socket.on('connect', () => manager.reconnectionDelay(RECONNECTION_DELAY));
138
+ };
139
+
140
+ export default function createSocketMethods(client: NuramaClient) {
141
+ // Track active socket connections
142
+ const activeConnections: Record<string, SocketChannel> = {};
143
+
144
+ // Extract configuration from client
145
+ const debug = client.debug;
146
+
147
+ /**
148
+ * Log helper for socket-related messages
149
+ */
150
+ const _log = (...args: any[]): void => {
151
+ if (debug) {
152
+ console.log('[SOCKET]', ...args);
153
+ }
154
+ };
155
+
156
+ // We'll need to dynamically import socket.io-client since it's not available in all environments
157
+ const getSocketIO = async (): Promise<any> => {
158
+ try {
159
+ // Handle different versions of socket.io-client
160
+ const socketModule = await import('socket.io-client');
161
+ // Socket.io 4.x exports the io function directly
162
+ return socketModule.default || socketModule;
163
+ } catch (error) {
164
+ throw new Error('socket.io-client is not installed. Please install it with: npm install socket.io-client');
165
+ }
166
+ };
167
+
168
+ /**
169
+ * Get an authentication token for WebSocket connection.
170
+ * Returns the long-lived API key when the client is in bot mode, otherwise the JWT.
171
+ */
172
+ const getAuthToken = (): string | null => {
173
+ return (client as any)._apiKey || (client as any)._getAccessToken();
174
+ };
175
+
176
+ /** True when the client is authenticating with a bot API key. */
177
+ const isApiKeyMode = (): boolean => Boolean((client as any)._apiKey);
178
+
179
+ /**
180
+ * Get the WebSocket server URL
181
+ *
182
+ * Priority:
183
+ * 1. Connection-specific websocketURL (from options)
184
+ * 2. Client-level websocketURL (from client options)
185
+ * 3. Use the baseURL directly - Socket.IO handles protocol conversion
186
+ */
187
+ const getWebSocketURL = (connectionOptions?: SocketOptions): string => {
188
+ // 1. Check connection-specific override
189
+ if (connectionOptions?.websocketURL) {
190
+ return connectionOptions.websocketURL;
191
+ }
192
+
193
+ // 2. Check client-level websocketURL
194
+ if (client.websocketURL) {
195
+ return client.websocketURL;
196
+ }
197
+
198
+ // 3. Use baseURL directly - Socket.IO handles protocol conversion
199
+ return client.baseURL;
200
+ };
201
+
202
+ /**
203
+ * Connect to a socket channel
204
+ * @param channel Channel path to connect to (e.g., '/user/123')
205
+ * @param options Socket connection options
206
+ * @returns Promise that resolves with the socket channel
207
+ */
208
+ const connect = async (channel: string, options: SocketOptions = {}): Promise<SocketChannel> => {
209
+ _log(`Connecting to channel: ${channel}`);
210
+
211
+ // Default options
212
+ const socketOptions = {
213
+ autoRefresh: true,
214
+ autoReconnect: true,
215
+ autoReconnectOnTokenExpiry: true,
216
+ debug: client.debug,
217
+ ...options
218
+ };
219
+
220
+ // Check if already connected
221
+ if (activeConnections[channel]) {
222
+ _log(`Already connected to channel: ${channel}`);
223
+ return activeConnections[channel];
224
+ }
225
+
226
+ // Get access token
227
+ const token = getAuthToken();
228
+ if (!token) {
229
+ throw new Error('Authentication required. Please login first.');
230
+ }
231
+
232
+ // Get WebSocket URL using the helper function - no protocol conversion needed
233
+ const wsUrl = getWebSocketURL(options);
234
+ _log(`Using Socket.IO URL: ${wsUrl}`);
235
+
236
+ // Dynamically import socket.io-client
237
+ const io = await getSocketIO();
238
+
239
+ // Create socket connection with enhanced reconnection configuration
240
+ const socket = io(`${wsUrl}${channel}`, {
241
+ query: { token },
242
+
243
+ // Reconnection configuration for resilience
244
+ reconnection: socketOptions.autoReconnect,
245
+ // Never give up: the server may refuse connections for a while (busy, or a deploy draining), and after a
246
+ // final failure socket.io stops for good without telling the app (the manager's 'reconnect_failed' never
247
+ // reaches socket listeners). Retries settle at one every ~30s.
248
+ reconnectionAttempts: Infinity,
249
+ reconnectionDelay: RECONNECTION_DELAY,
250
+ reconnectionDelayMax: RECONNECTION_DELAY_MAX,
251
+ randomizationFactor: 0.5, // Add jitter to avoid thundering herd problem
252
+
253
+ // Connection timeout
254
+ timeout: 20000, // 20 seconds to establish initial connection
255
+
256
+ // Transport configuration
257
+ transports: ['websocket', 'polling'], // Prefer WebSocket, fallback to polling
258
+ upgrade: true, // Allow transport upgrade
259
+ rememberUpgrade: true, // Remember successful upgrade
260
+
261
+ // Ping/pong for connection health
262
+ forceNew: false, // Reuse existing connection if available
263
+ });
264
+
265
+ // Create channel object
266
+ const socketChannel: SocketChannel = {
267
+ socket,
268
+ channel,
269
+ listeners: {},
270
+ };
271
+
272
+ // Store connection immediately so subscribers can register socket.on handlers
273
+ // even before the connection is fully established. Socket.io preserves
274
+ // handlers across reconnections, so they will fire once connected.
275
+ activeConnections[channel] = socketChannel;
276
+
277
+ spreadReconnectAfterServerClose(socket);
278
+
279
+ // Setup event listeners
280
+ return new Promise((resolve, reject) => {
281
+ let settled = false;
282
+ let hasConnected = false;
283
+
284
+ // Handle connection (initial or after reconnection)
285
+ socket.on('connect', () => {
286
+ _log(`Connected to channel: ${channel}`);
287
+ activeConnections[channel] = socketChannel;
288
+ if (hasConnected) {
289
+ // A second 'connect' on the same socket is socket.io reconnecting it (the manager emits 'reconnect',
290
+ // but socket listeners never see that). Notify app-level onReconnect listeners so they can
291
+ // REST-refetch any gap of server->client messages missed while the transport was down (socket.io has
292
+ // no replay across a reconnect).
293
+ _log(`Reconnected to channel ${channel}`);
294
+ const reconnectListeners = socketChannel.listeners['reconnect'] || new Set();
295
+ reconnectListeners.forEach(listener => listener({ type: 'reconnect' }));
296
+ }
297
+ hasConnected = true;
298
+ if (!settled) {
299
+ settled = true;
300
+ resolve(socketChannel);
301
+ }
302
+ });
303
+
304
+ // Handle connection error — log but let socket.io retry
305
+ socket.on('connect_error', (error: unknown) => {
306
+ _log(`Connection error for channel ${channel}:`, error);
307
+ // Don't reject — socket.io's auto-reconnect will keep trying
308
+ // Resolve the promise so callers aren't blocked — subscriptions
309
+ // registered via socket.on will fire once the connection succeeds
310
+ if (!settled) {
311
+ settled = true;
312
+ resolve(socketChannel);
313
+ }
314
+ });
315
+
316
+ // Handle reconnection failure — only reject when socket.io gives up entirely
317
+ socket.on('reconnect_failed', () => {
318
+ _log(`Reconnection failed for channel ${channel} after maximum attempts`);
319
+ // Emit to listeners for UI feedback
320
+ const eventListeners = socketChannel.listeners['reconnect_failed'] || new Set();
321
+ eventListeners.forEach(listener => listener({ type: 'reconnect_failed' }));
322
+ if (!settled) {
323
+ settled = true;
324
+ reject(new Error(`Connection to ${channel} failed after maximum reconnection attempts`));
325
+ }
326
+ });
327
+
328
+ // Handle token events
329
+ socket.on('tokenEvent', (event: string) => {
330
+ _log(`Token event on channel ${channel}:`, event);
331
+
332
+ if (event === 'TokenExpired' && socketOptions.autoReconnectOnTokenExpiry) {
333
+ // API keys don't expire client-side; if the server emits TokenExpired
334
+ // for a bot, treat it as terminal — there's nothing to refresh.
335
+ if (isApiKeyMode()) {
336
+ _log('Token expired event in API key mode — disconnecting (no refresh path)');
337
+ disconnect(channel);
338
+ const eventListeners = socketChannel.listeners['tokenExpired'] || new Set();
339
+ eventListeners.forEach(listener => listener({ type: 'tokenExpired' }));
340
+ return;
341
+ }
342
+
343
+ // Refresh token and reconnect
344
+ _log('Token expired, attempting to refresh and reconnect');
345
+
346
+ // Call the client's _refreshToken method (using 'as any' for TypeScript)
347
+ (client as any)._refreshToken()
348
+ .then(() => {
349
+ _log('Token refreshed, reconnecting');
350
+ // Preserve event subscriptions across the reconnect. connect()
351
+ // builds a brand-new socket whose listeners map starts empty, so
352
+ // without re-binding these the channel reconnects but silently
353
+ // stops delivering 'notification' events — the app-level
354
+ // subscription is lost until a full page reload. This fires on the
355
+ // server's token-expiry sweep (~every 30 min for a long-lived
356
+ // session), so the reconnect must carry the subscriptions over.
357
+ const preservedListeners = socketChannel.listeners;
358
+ disconnect(channel)
359
+ .then(() => connect(channel, options))
360
+ .then((newConnection) => {
361
+ Object.entries(preservedListeners).forEach(([listenerEvent, callbacks]) => {
362
+ callbacks.forEach((callback) => {
363
+ if (LIFECYCLE_LISTENER_EVENTS.has(listenerEvent)) {
364
+ // Built-in handlers on the fresh socket already dispatch
365
+ // these; just repopulate the set they read from.
366
+ if (!newConnection.listeners[listenerEvent]) {
367
+ newConnection.listeners[listenerEvent] = new Set();
368
+ }
369
+ newConnection.listeners[listenerEvent].add(callback);
370
+ } else {
371
+ subscribe(channel, listenerEvent, callback as (e: any) => void);
372
+ }
373
+ });
374
+ });
375
+ // The fresh socket fired 'connect', not 'reconnect', so notify
376
+ // onReconnect listeners here too — a token-refresh reconnect is
377
+ // still a gap that needs the same REST catch-up.
378
+ (newConnection.listeners['reconnect'] || new Set()).forEach(listener =>
379
+ listener({ type: 'reconnect', reason: 'tokenRefresh' }));
380
+ })
381
+ .catch((error: unknown) => {
382
+ _log(`Failed to re-establish subscriptions after token refresh on ${channel}:`, error);
383
+ });
384
+ })
385
+ .catch((error: unknown) => {
386
+ _log('Failed to refresh token', error);
387
+ // Emit disconnect event to listeners
388
+ const eventListeners = socketChannel.listeners['tokenExpired'] || new Set();
389
+ eventListeners.forEach(listener => listener({ type: 'tokenExpired', error }));
390
+ });
391
+ } else if (event === 'tokenBlacklisted') {
392
+ // Force disconnect
393
+ _log('Token blacklisted, disconnecting');
394
+ disconnect(channel);
395
+
396
+ // Emit disconnect event to listeners
397
+ const eventListeners = socketChannel.listeners['tokenBlacklisted'] || new Set();
398
+ eventListeners.forEach(listener => listener({ type: 'tokenBlacklisted' }));
399
+ }
400
+ });
401
+
402
+ // Handle disconnect
403
+ socket.on('disconnect', (reason: string) => {
404
+ _log(`Disconnected from channel ${channel}:`, reason);
405
+
406
+ // Emit disconnect event to listeners
407
+ const eventListeners = socketChannel.listeners['disconnect'] || new Set();
408
+ eventListeners.forEach(listener => listener({ type: 'disconnect', reason }));
409
+
410
+ // Remove from active connections
411
+ delete activeConnections[channel];
412
+ });
413
+ });
414
+ };
415
+
416
+ /**
417
+ * Connect to a public socket channel without authentication
418
+ * @param publicToken Public file system token (10-character alphanumeric)
419
+ * @param options Socket connection options
420
+ * @returns Promise that resolves with the socket channel
421
+ */
422
+ const connectPublic = async (publicToken: string, options: PublicSocketOptions = {}): Promise<SocketChannel> => {
423
+ const channel = `public/${publicToken}`;
424
+ _log(`Connecting to public channel: ${channel}`);
425
+
426
+ // Default options
427
+ const socketOptions = {
428
+ autoReconnect: true,
429
+ debug: client.debug,
430
+ ...options
431
+ };
432
+
433
+ // Check if already connected
434
+ if (activeConnections[channel]) {
435
+ _log(`Already connected to public channel: ${channel}`);
436
+ return activeConnections[channel];
437
+ }
438
+
439
+ // Get WebSocket URL using the helper function
440
+ const wsUrl = getWebSocketURL(options);
441
+ _log(`Using Socket.IO URL for public connection: ${wsUrl}`);
442
+
443
+ // Dynamically import socket.io-client
444
+ const io = await getSocketIO();
445
+
446
+ // Create socket connection - no token needed for public channels
447
+ const socket = io(`${wsUrl}${channel}`, {
448
+ query: { publicToken },
449
+
450
+ // Reconnection configuration for resilience
451
+ reconnection: socketOptions.autoReconnect,
452
+ reconnectionAttempts: Infinity, // as connect(): never give up silently
453
+ reconnectionDelay: RECONNECTION_DELAY,
454
+ reconnectionDelayMax: RECONNECTION_DELAY_MAX,
455
+ randomizationFactor: 0.5,
456
+
457
+ // Connection timeout
458
+ timeout: 20000,
459
+
460
+ // Transport configuration
461
+ transports: ['websocket', 'polling'],
462
+ upgrade: true,
463
+ rememberUpgrade: true,
464
+
465
+ forceNew: false,
466
+ });
467
+
468
+ // Create channel object
469
+ const socketChannel: SocketChannel = {
470
+ socket,
471
+ channel,
472
+ listeners: {},
473
+ };
474
+
475
+ spreadReconnectAfterServerClose(socket);
476
+
477
+ // Setup event listeners
478
+ return new Promise((resolve, reject) => {
479
+ // Handle connection
480
+ socket.on('connect', () => {
481
+ _log(`Connected to public channel: ${channel}`);
482
+ activeConnections[channel] = socketChannel;
483
+ resolve(socketChannel);
484
+ });
485
+
486
+ // Handle connection error
487
+ socket.on('connect_error', (error: unknown) => {
488
+ _log(`Connection error for public channel ${channel}:`, error);
489
+ reject(error);
490
+ });
491
+
492
+ // Handle reconnection failure
493
+ socket.on('reconnect_failed', () => {
494
+ _log(`Reconnection failed for public channel ${channel} after maximum attempts`);
495
+ const eventListeners = socketChannel.listeners['reconnect_failed'] || new Set();
496
+ eventListeners.forEach(listener => listener({ type: 'reconnect_failed' }));
497
+ });
498
+
499
+ // Handle disconnect
500
+ socket.on('disconnect', (reason: string) => {
501
+ _log(`Disconnected from public channel ${channel}:`, reason);
502
+
503
+ // Emit disconnect event to listeners
504
+ const eventListeners = socketChannel.listeners['disconnect'] || new Set();
505
+ eventListeners.forEach(listener => listener({ type: 'disconnect', reason }));
506
+
507
+ // Remove from active connections
508
+ delete activeConnections[channel];
509
+ });
510
+ });
511
+ };
512
+
513
+ /**
514
+ * Disconnect from a socket channel
515
+ * @param channel Channel to disconnect from
516
+ */
517
+ const disconnect = async (channel: string): Promise<void> => {
518
+ const connection = activeConnections[channel];
519
+ if (connection) {
520
+ _log(`Disconnecting from channel: ${channel}`);
521
+ connection.socket.disconnect();
522
+ delete activeConnections[channel];
523
+ }
524
+ };
525
+
526
+ /**
527
+ * Subscribe to an event on a channel
528
+ * @param channel Channel to subscribe to
529
+ * @param event Event name to listen for
530
+ * @param callback Callback function to handle events
531
+ */
532
+ const subscribe = async <T = BaseNotificationEvent>(
533
+ channel: string,
534
+ event: string,
535
+ callback: (event: T) => void
536
+ ): Promise<void> => {
537
+ // Get or create connection
538
+ let connection = activeConnections[channel];
539
+ if (!connection) {
540
+ connection = await connect(channel);
541
+ }
542
+
543
+ // Initialize listeners set for this event if needed
544
+ if (!connection.listeners[event]) {
545
+ connection.listeners[event] = new Set();
546
+
547
+ // Add socket listener for this event
548
+ connection.socket.on(event, (eventData: T) => {
549
+ _log(`Received event '${event}' on channel ${channel}:`, eventData);
550
+
551
+ // Call all registered callbacks
552
+ const eventListeners = connection.listeners[event] || new Set();
553
+ eventListeners.forEach(listener => listener(eventData));
554
+ });
555
+ }
556
+
557
+ // Add callback to listeners
558
+ connection.listeners[event].add(callback as any);
559
+ _log(`Subscribed to event '${event}' on channel ${channel}`);
560
+ };
561
+
562
+ /**
563
+ * Subscribe to an event on a public channel
564
+ * Automatically connects to the public channel if not already connected
565
+ * @param publicToken Public file system token (10-character alphanumeric)
566
+ * @param event Event name to listen for
567
+ * @param callback Callback function to handle events
568
+ */
569
+ const subscribePublic = async <T = BaseNotificationEvent>(
570
+ publicToken: string,
571
+ event: string,
572
+ callback: (event: T) => void
573
+ ): Promise<void> => {
574
+ const channel = `public/${publicToken}`;
575
+
576
+ // Get or create public connection
577
+ let connection = activeConnections[channel];
578
+ if (!connection) {
579
+ connection = await connectPublic(publicToken);
580
+ }
581
+
582
+ // Initialize listeners set for this event if needed
583
+ if (!connection.listeners[event]) {
584
+ connection.listeners[event] = new Set();
585
+
586
+ // Add socket listener for this event
587
+ connection.socket.on(event, (eventData: T) => {
588
+ _log(`Received event '${event}' on public channel ${channel}:`, eventData);
589
+
590
+ // Call all registered callbacks
591
+ const eventListeners = connection.listeners[event] || new Set();
592
+ eventListeners.forEach(listener => listener(eventData));
593
+ });
594
+ }
595
+
596
+ // Add callback to listeners
597
+ connection.listeners[event].add(callback as any);
598
+ _log(`Subscribed to event '${event}' on public channel ${channel}`);
599
+ };
600
+
601
+ /**
602
+ * Unsubscribe from an event on a channel
603
+ * @param channel Channel to unsubscribe from
604
+ * @param event Event name to stop listening for
605
+ * @param callback Callback function to remove (if not provided, all callbacks will be removed)
606
+ */
607
+ /**
608
+ * Stop listening for an event on a channel. If `callback` is provided,
609
+ * only that specific listener is removed; otherwise every listener for
610
+ * that event is cleared.
611
+ *
612
+ * @param channel Channel to unsubscribe from
613
+ * @param event Event name to stop listening for
614
+ * @param callback Specific callback to remove (optional)
615
+ */
616
+ const unsubscribe = (
617
+ channel: string,
618
+ event: string,
619
+ callback?: (event: any) => void
620
+ ): void => {
621
+ const connection = activeConnections[channel];
622
+ if (!connection || !connection.listeners[event]) {
623
+ return;
624
+ }
625
+
626
+ if (callback) {
627
+ // Remove specific callback
628
+ connection.listeners[event].delete(callback);
629
+ _log(`Unsubscribed specific callback from event '${event}' on channel ${channel}`);
630
+ } else {
631
+ // Remove all callbacks
632
+ connection.listeners[event].clear();
633
+ connection.socket.off(event);
634
+ _log(`Unsubscribed all callbacks from event '${event}' on channel ${channel}`);
635
+ }
636
+ };
637
+
638
+ /**
639
+ * Check if connected to a channel
640
+ * @param channel Channel to check
641
+ * @returns True if connected, false otherwise
642
+ */
643
+ const isConnected = (channel: string): boolean => {
644
+ return !!activeConnections[channel]?.socket.connected;
645
+ };
646
+
647
+ /**
648
+ * Emit an event to a connected channel
649
+ * @param channel Channel to emit to
650
+ * @param event Event name
651
+ * @param data Event data
652
+ */
653
+ const emit = (channel: string, event: string, data?: unknown): void => {
654
+ const connection = activeConnections[channel];
655
+ if (!connection?.socket.connected) {
656
+ _log(`Cannot emit to channel ${channel}: not connected`);
657
+ return;
658
+ }
659
+ _log(`Emitting '${event}' on channel ${channel}`);
660
+ connection.socket.emit(event, data);
661
+ };
662
+
663
+ /**
664
+ * Emit an event with a timeout-bounded server acknowledgement. Resolves
665
+ * `true` if the server acks within `timeoutMs`, `false` on timeout or
666
+ * transport error. Used by the FE WS layer (`WebSocketManager.probeChannel`)
667
+ * to actively verify a channel's liveness when `socket.connected` may be
668
+ * stale — most notably after a backgrounded tab returns to focus, where
669
+ * the flag can remain `true` for up to socket.io's own heartbeat window
670
+ * (~25–45s) even after the underlying TCP transport has died.
671
+ *
672
+ * Relies on socket.io v4's `socket.timeout(ms).emit(ev, data, cb)`
673
+ * pattern: the BE handler invokes `ack()` (its trailing callback arg);
674
+ * if no ack arrives within `timeoutMs` the cb is invoked with an Error.
675
+ */
676
+ const emitWithAck = (
677
+ channel: string,
678
+ event: string,
679
+ data: unknown,
680
+ timeoutMs: number,
681
+ ): Promise<boolean> => {
682
+ const connection = activeConnections[channel];
683
+ if (!connection) return Promise.resolve(false);
684
+ return new Promise<boolean>((resolve) => {
685
+ try {
686
+ connection.socket.timeout(timeoutMs).emit(event, data, (err: Error | null) => {
687
+ resolve(!err);
688
+ });
689
+ } catch (error) {
690
+ _log(`emitWithAck error on channel ${channel}:`, error);
691
+ resolve(false);
692
+ }
693
+ });
694
+ };
695
+
696
+ /**
697
+ * Disconnect from all channels
698
+ */
699
+ const disconnectAll = async (): Promise<void> => {
700
+ _log('Disconnecting from all channels');
701
+ const channels = Object.keys(activeConnections);
702
+ for (const channel of channels) {
703
+ await disconnect(channel);
704
+ }
705
+ };
706
+
707
+ /**
708
+ * Register a callback for when Socket.IO exhausts all reconnection attempts
709
+ * @param channel Channel to monitor
710
+ * @param callback Callback invoked when reconnection fails permanently
711
+ */
712
+ const onReconnectFailed = (channel: string, callback: () => void): void => {
713
+ const connection = activeConnections[channel];
714
+ if (connection) {
715
+ // Add to the channel's listener set so the existing reconnect_failed
716
+ // handler in connect()/connectPublic() dispatches to it
717
+ if (!connection.listeners['reconnect_failed']) {
718
+ connection.listeners['reconnect_failed'] = new Set();
719
+ }
720
+ connection.listeners['reconnect_failed'].add(callback);
721
+ }
722
+ };
723
+
724
+ /**
725
+ * Register a callback for when the channel reconnects — a socket.io
726
+ * transport-level reconnect, or the token-refresh reconnect. Use it to recover
727
+ * any gap of server->client messages missed while the connection was down;
728
+ * socket.io does not replay those. Dispatched from the 'reconnect' handler in
729
+ * connect() and from the token-refresh path.
730
+ */
731
+ const onReconnect = (channel: string, callback: (event?: unknown) => void): void => {
732
+ const connection = activeConnections[channel];
733
+ if (connection) {
734
+ if (!connection.listeners['reconnect']) {
735
+ connection.listeners['reconnect'] = new Set();
736
+ }
737
+ connection.listeners['reconnect'].add(callback);
738
+ }
739
+ };
740
+
741
+ return {
742
+ connect,
743
+ connectPublic,
744
+ disconnect,
745
+ disconnectAll,
746
+ subscribe,
747
+ subscribePublic,
748
+ unsubscribe,
749
+ isConnected,
750
+ emit,
751
+ emitWithAck,
752
+ onReconnect,
753
+ onReconnectFailed,
754
+ };
755
+ }
756
+
757
+ export type SocketMethods = ReturnType<typeof createSocketMethods>;