@pushler/js 1.1.1 → 1.2.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.
@@ -1,5 +1,5 @@
1
1
  import Channel from './Channel';
2
- import { ChannelTypes, ConnectionStates, Events, ErrorCodes } from './constants';
2
+ import { ChannelTypes, ConnectionStates, SubscriptionStates, Events, ErrorCodes } from './constants';
3
3
 
4
4
  /**
5
5
  * Основной класс для подключения к Pushler.ru WebSocket серверу
@@ -23,6 +23,7 @@ class PushlerClient {
23
23
  this.channels = new Map(); // Хранит каналы по полному имени (с appKey)
24
24
  this.channelAliases = new Map(); // Маппинг оригинальное имя -> полное имя
25
25
  this.eventListeners = new Map();
26
+ this.pendingAuthRequests = new Map(); // Отслеживание pending auth запросов для отмены при unsubscribe
26
27
  this.reconnectAttempts = 0;
27
28
  this.reconnectTimer = null;
28
29
  this.authTimeout = null;
@@ -211,31 +212,98 @@ class PushlerClient {
211
212
  };
212
213
  }
213
214
 
215
+ /**
216
+ * Разбор склеенных JSON из одного WebSocket фрейма
217
+ * Сервер иногда отправляет несколько JSON объектов подряд в одном фрейме:
218
+ * {"event":"a",...}{"event":"b",...}
219
+ * @param {string} str - Строка с одним или несколькими JSON объектами
220
+ * @returns {string[]} Массив отдельных JSON строк
221
+ */
222
+ splitConcatenatedJSON(str) {
223
+ const results = [];
224
+ let depth = 0;
225
+ let start = 0;
226
+ let inString = false;
227
+ let escape = false;
228
+
229
+ for (let i = 0; i < str.length; i++) {
230
+ const char = str[i];
231
+
232
+ // Обработка escape-символов внутри строк
233
+ if (escape) {
234
+ escape = false;
235
+ continue;
236
+ }
237
+
238
+ if (char === '\\' && inString) {
239
+ escape = true;
240
+ continue;
241
+ }
242
+
243
+ // Отслеживание строковых литералов
244
+ if (char === '"') {
245
+ inString = !inString;
246
+ continue;
247
+ }
248
+
249
+ // Считаем скобки только вне строк
250
+ if (!inString) {
251
+ if (char === '{') {
252
+ depth++;
253
+ } else if (char === '}') {
254
+ depth--;
255
+ if (depth === 0) {
256
+ results.push(str.slice(start, i + 1));
257
+ start = i + 1;
258
+ }
259
+ }
260
+ }
261
+ }
262
+
263
+ return results;
264
+ }
265
+
214
266
  /**
215
267
  * Обработка входящих сообщений
216
268
  */
217
269
  handleMessage(event) {
218
270
  try {
219
- const message = JSON.parse(event.data);
271
+ // Пытаемся распарсить как один JSON
272
+ const messages = this.splitConcatenatedJSON(event.data);
220
273
 
221
- switch (message.event) {
222
- case 'pushler:connection_established':
223
- this.handleConnectionEstablished(message.data);
224
- break;
225
- case 'pushler:subscription_succeeded':
226
- this.handleSubscriptionSucceeded(message);
227
- break;
228
- case 'pushler:auth_success':
229
- this.handleAuthSuccess(message);
230
- break;
231
- case 'pushler:auth_error':
232
- this.handleAuthError(message);
233
- break;
234
- default:
235
- this.handleChannelMessage(message);
274
+ // Если несколько JSON объектов, обрабатываем каждый
275
+ for (const jsonStr of messages) {
276
+ try {
277
+ const message = JSON.parse(jsonStr);
278
+ this.processMessage(message);
279
+ } catch (parseError) {
280
+ console.error('Error parsing JSON message:', parseError, 'Raw:', jsonStr);
281
+ }
236
282
  }
237
283
  } catch (error) {
238
- console.error('Error parsing WebSocket message:', error);
284
+ console.error('Error handling WebSocket message:', error);
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Обработка распарсенного сообщения
290
+ */
291
+ processMessage(message) {
292
+ switch (message.event) {
293
+ case 'pushler:connection_established':
294
+ this.handleConnectionEstablished(message.data);
295
+ break;
296
+ case 'pushler:subscription_succeeded':
297
+ this.handleSubscriptionSucceeded(message);
298
+ break;
299
+ case 'pushler:auth_success':
300
+ this.handleAuthSuccess(message);
301
+ break;
302
+ case 'pushler:auth_error':
303
+ this.handleAuthError(message);
304
+ break;
305
+ default:
306
+ this.handleChannelMessage(message);
239
307
  }
240
308
  }
241
309
 
@@ -296,7 +364,13 @@ class PushlerClient {
296
364
  const { channel } = message.data;
297
365
  const channelInstance = this.channels.get(channel);
298
366
 
367
+ // Очищаем pending auth запрос
368
+ this.cancelPendingAuth(channel);
369
+
299
370
  if (channelInstance) {
371
+ // Устанавливаем состояние subscribed
372
+ channelInstance.subscriptionState = SubscriptionStates.SUBSCRIBED;
373
+
300
374
  // На сервере уже происходит подписка после успешной авторизации,
301
375
  // поэтому просто помечаем канал как подписанный
302
376
  channelInstance.handleAuthSuccess(message.data);
@@ -309,11 +383,31 @@ class PushlerClient {
309
383
  * Обработка ошибки аутентификации
310
384
  */
311
385
  handleAuthError(message) {
312
- const { error } = message.data;
313
- this.emit(Events.CHANNEL_AUTH_ERROR, error);
386
+ const { error, channel } = message.data;
387
+ const channelName = channel || null;
388
+
389
+ // Очищаем pending auth запрос
390
+ if (channelName) {
391
+ this.cancelPendingAuth(channelName);
392
+
393
+ const channelInstance = this.channels.get(channelName);
394
+ if (channelInstance) {
395
+ channelInstance.subscriptionState = SubscriptionStates.FAILED;
396
+ }
397
+ }
398
+
399
+ const originalChannelName = channelName ? this.extractChannelName(channelName) : null;
400
+
401
+ this.emit(Events.CHANNEL_AUTH_ERROR, {
402
+ error,
403
+ channel: originalChannelName,
404
+ socketId: this.socketId
405
+ });
314
406
  this.emit(Events.ERROR, {
315
407
  code: ErrorCodes.AUTHENTICATION_FAILED,
316
- message: error
408
+ message: error,
409
+ channel: originalChannelName,
410
+ socketId: this.socketId
317
411
  });
318
412
  }
319
413
 
@@ -371,8 +465,12 @@ class PushlerClient {
371
465
  // Поддерживаем оба формата имени
372
466
  const fullChannelName = this.channelAliases.get(channelName) || this.formatChannelName(channelName);
373
467
 
468
+ // Отменяем pending auth запросы для этого канала (исправление race condition)
469
+ this.cancelPendingAuth(fullChannelName);
470
+
374
471
  const channel = this.channels.get(fullChannelName);
375
472
  if (channel) {
473
+ channel.subscriptionState = SubscriptionStates.UNSUBSCRIBED;
376
474
  channel.unsubscribe();
377
475
  this.channels.delete(fullChannelName);
378
476
  this.channelAliases.delete(channel.originalName || channelName);
@@ -407,41 +505,119 @@ class PushlerClient {
407
505
 
408
506
  /**
409
507
  * Аутентификация канала
508
+ * С поддержкой отмены при unsubscribe
410
509
  */
411
510
  async authenticateChannel(channel) {
511
+ // Создаём AbortController для возможности отмены
512
+ const abortController = new AbortController();
513
+ const channelName = channel.name;
514
+
515
+ // Сохраняем для возможности отмены при unsubscribe
516
+ this.pendingAuthRequests.set(channelName, {
517
+ controller: abortController,
518
+ timeoutId: null
519
+ });
520
+
521
+ // Устанавливаем состояние канала
522
+ channel.subscriptionState = SubscriptionStates.PENDING_AUTH;
523
+
412
524
  try {
525
+ // Проверка отмены перед началом
526
+ if (abortController.signal.aborted) {
527
+ return;
528
+ }
529
+
413
530
  const authData = await this.getChannelAuthData(channel);
414
531
 
532
+ // Проверка отмены после получения данных
533
+ if (abortController.signal.aborted) {
534
+ return;
535
+ }
536
+
415
537
  this.sendMessage({
416
538
  event: 'pushler:auth',
417
539
  data: authData
418
540
  });
419
541
 
420
542
  // Устанавливаем таймаут аутентификации
421
- this.authTimeout = setTimeout(() => {
543
+ const timeoutId = setTimeout(() => {
544
+ // Не отправляем ошибку, если запрос уже отменён
545
+ if (abortController.signal.aborted) {
546
+ return;
547
+ }
548
+
549
+ // Очищаем pending запрос
550
+ this.pendingAuthRequests.delete(channelName);
551
+
552
+ // Устанавливаем состояние failed
553
+ channel.subscriptionState = SubscriptionStates.FAILED;
554
+
422
555
  this.emit(Events.ERROR, {
423
556
  code: ErrorCodes.AUTHENTICATION_TIMEOUT,
424
- message: 'Authentication timeout'
557
+ message: 'Authentication timeout',
558
+ channel: channel.originalName || this.extractChannelName(channelName),
559
+ socketId: this.socketId
425
560
  });
426
561
  }, 10000);
427
562
 
563
+ // Сохраняем timeoutId для возможности очистки
564
+ const pending = this.pendingAuthRequests.get(channelName);
565
+ if (pending) {
566
+ pending.timeoutId = timeoutId;
567
+ }
568
+
428
569
  } catch (error) {
570
+ // Не отправляем ошибку, если запрос уже отменён
571
+ if (abortController.signal.aborted) {
572
+ return;
573
+ }
574
+
575
+ // Очищаем pending запрос
576
+ this.pendingAuthRequests.delete(channelName);
577
+
578
+ // Устанавливаем состояние failed
579
+ channel.subscriptionState = SubscriptionStates.FAILED;
580
+
429
581
  this.emit(Events.ERROR, {
430
582
  code: ErrorCodes.AUTHENTICATION_FAILED,
431
- message: error.message
583
+ message: error.message,
584
+ channel: channel.originalName || this.extractChannelName(channelName),
585
+ socketId: this.socketId
432
586
  });
433
587
  }
434
588
  }
435
589
 
590
+ /**
591
+ * Отмена pending auth запроса для канала
592
+ */
593
+ cancelPendingAuth(channelName) {
594
+ const pending = this.pendingAuthRequests.get(channelName);
595
+ if (pending) {
596
+ // Отменяем запрос
597
+ pending.controller.abort();
598
+
599
+ // Очищаем таймаут
600
+ if (pending.timeoutId) {
601
+ clearTimeout(pending.timeoutId);
602
+ }
603
+
604
+ // Удаляем из карты
605
+ this.pendingAuthRequests.delete(channelName);
606
+ }
607
+ }
608
+
436
609
  /**
437
610
  * Получение заголовков для авторизации
611
+ * Поддерживает как синхронные, так и асинхронные функции getAuthHeaders
612
+ * @returns {Promise<Object>} Заголовки для авторизации
438
613
  */
439
- getAuthRequestHeaders() {
614
+ async getAuthRequestHeaders() {
440
615
  const headers = { 'Content-Type': 'application/json' };
441
616
 
442
617
  // Динамические заголовки имеют приоритет
443
618
  if (typeof this.getAuthHeaders === 'function') {
444
- const dynamicHeaders = this.getAuthHeaders();
619
+ // Поддержка асинхронных функций (AsyncStorage, SecureStore, Keychain)
620
+ const dynamicHeaders = await Promise.resolve(this.getAuthHeaders());
445
621
  Object.assign(headers, dynamicHeaders);
446
622
  } else if (this.authHeaders) {
447
623
  // Статические заголовки
@@ -451,6 +627,19 @@ class PushlerClient {
451
627
  return headers;
452
628
  }
453
629
 
630
+ /**
631
+ * Установка токена авторизации
632
+ * Удобный метод для установки Bearer токена без создания функции
633
+ * @param {string} token - JWT токен
634
+ */
635
+ setAuthToken(token) {
636
+ if (token) {
637
+ this.authHeaders = { 'Authorization': `Bearer ${token}` };
638
+ } else {
639
+ this.authHeaders = null;
640
+ }
641
+ }
642
+
454
643
  /**
455
644
  * Получение подписи с бэкенда
456
645
  */
@@ -460,7 +649,8 @@ class PushlerClient {
460
649
  ? this.authEndpoint
461
650
  : `${this.apiUrl}${this.authEndpoint}`;
462
651
 
463
- const headers = this.getAuthRequestHeaders();
652
+ // Асинхронное получение заголовков (поддержка AsyncStorage/SecureStore)
653
+ const headers = await this.getAuthRequestHeaders();
464
654
  const hasAuthHeaders = this.authHeaders || this.getAuthHeaders;
465
655
 
466
656
  const response = await fetch(authUrl, {
@@ -717,6 +907,69 @@ class PushlerClient {
717
907
  const fullChannelName = this.channelAliases.get(channelName) || this.formatChannelName(channelName);
718
908
  return this.channels.get(fullChannelName);
719
909
  }
910
+
911
+ /**
912
+ * Проверка подписки на канал
913
+ * @param {string} channelName - Имя канала
914
+ * @returns {boolean} true если подписан
915
+ */
916
+ isSubscribed(channelName) {
917
+ const fullChannelName = this.channelAliases.get(channelName) || this.formatChannelName(channelName);
918
+ const channel = this.channels.get(fullChannelName);
919
+ return channel ? channel.subscribed : false;
920
+ }
921
+
922
+ /**
923
+ * Получение состояния подписки на канал
924
+ * @param {string} channelName - Имя канала
925
+ * @returns {string|null} Состояние подписки или null если канал не найден
926
+ */
927
+ getSubscriptionState(channelName) {
928
+ const fullChannelName = this.channelAliases.get(channelName) || this.formatChannelName(channelName);
929
+ const channel = this.channels.get(fullChannelName);
930
+
931
+ if (!channel) {
932
+ return null;
933
+ }
934
+
935
+ return channel.subscriptionState || (channel.subscribed ? SubscriptionStates.SUBSCRIBED : SubscriptionStates.PENDING);
936
+ }
937
+
938
+ /**
939
+ * Получение списка подписанных каналов
940
+ * @returns {string[]} Массив имён подписанных каналов
941
+ */
942
+ getSubscribedChannels() {
943
+ const subscribedChannels = [];
944
+
945
+ for (const [fullName, channel] of this.channels.entries()) {
946
+ if (channel.subscribed) {
947
+ subscribedChannels.push(channel.originalName || this.extractChannelName(fullName));
948
+ }
949
+ }
950
+
951
+ return subscribedChannels;
952
+ }
953
+
954
+ /**
955
+ * Получение всех каналов с их состояниями
956
+ * @returns {Object[]} Массив объектов с информацией о каналах
957
+ */
958
+ getAllChannelsInfo() {
959
+ const channelsInfo = [];
960
+
961
+ for (const [fullName, channel] of this.channels.entries()) {
962
+ channelsInfo.push({
963
+ name: channel.originalName || this.extractChannelName(fullName),
964
+ fullName: fullName,
965
+ subscribed: channel.subscribed,
966
+ state: channel.subscriptionState || (channel.subscribed ? SubscriptionStates.SUBSCRIBED : SubscriptionStates.PENDING),
967
+ type: channel.getType()
968
+ });
969
+ }
970
+
971
+ return channelsInfo;
972
+ }
720
973
  }
721
974
 
722
975
  export default PushlerClient;
package/src/constants.js CHANGED
@@ -18,6 +18,18 @@ export const ConnectionStates = {
18
18
  FAILED: 'failed'
19
19
  };
20
20
 
21
+ /**
22
+ * Состояния подписки на канал
23
+ */
24
+ export const SubscriptionStates = {
25
+ PENDING: 'pending',
26
+ PENDING_AUTH: 'pending_auth',
27
+ SUBSCRIBING: 'subscribing',
28
+ SUBSCRIBED: 'subscribed',
29
+ FAILED: 'failed',
30
+ UNSUBSCRIBED: 'unsubscribed'
31
+ };
32
+
21
33
  /**
22
34
  * События WebSocket
23
35
  */
package/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import PushlerClient from './PushlerClient';
2
2
  import PushlerServer from './PushlerServer';
3
3
  import Channel from './Channel';
4
- import { ChannelTypes, ConnectionStates } from './constants';
4
+ import { ChannelTypes, ConnectionStates, SubscriptionStates, ErrorCodes, Events } from './constants';
5
5
  import { generateSocketId } from './utils';
6
6
 
7
7
  // Создаем объект с методами для удобного API
@@ -17,8 +17,22 @@ const Pushler = {
17
17
  Channel,
18
18
  ChannelTypes,
19
19
  ConnectionStates,
20
+ SubscriptionStates,
21
+ ErrorCodes,
22
+ Events,
20
23
  generateSocketId
21
24
  };
22
25
 
23
- export { PushlerClient, PushlerServer, Channel, ChannelTypes, ConnectionStates, generateSocketId, Pushler };
26
+ export {
27
+ PushlerClient,
28
+ PushlerServer,
29
+ Channel,
30
+ ChannelTypes,
31
+ ConnectionStates,
32
+ SubscriptionStates,
33
+ ErrorCodes,
34
+ Events,
35
+ generateSocketId,
36
+ Pushler
37
+ };
24
38
  export default Pushler;