@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.
- package/dist/pushler-ru.cjs +308 -26
- package/dist/pushler-ru.cjs.map +1 -1
- package/dist/pushler-ru.esm.js +306 -27
- package/dist/pushler-ru.esm.js.map +1 -1
- package/dist/pushler-ru.js +308 -26
- package/dist/pushler-ru.js.map +1 -1
- package/dist/pushler-ru.min.js +1 -1
- package/dist/pushler-ru.min.js.map +1 -1
- package/package.json +1 -1
- package/src/Channel.js +12 -1
- package/src/PushlerClient.js +280 -27
- package/src/constants.js +12 -0
- package/src/index.js +16 -2
package/src/PushlerClient.js
CHANGED
|
@@ -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
|
-
|
|
271
|
+
// Пытаемся распарсить как один JSON
|
|
272
|
+
const messages = this.splitConcatenatedJSON(event.data);
|
|
220
273
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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;
|