@nurama/sdk 0.0.0-stage → 1.4.0

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