vimp-engine 0.37.1 → 0.38.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.
- package/package.json +1 -1
- package/src/client/boot.js +1 -1
- package/src/client/components/controller/ClientReports.js +2 -1
- package/src/client/components/controller/Games.js +1 -1
- package/src/client/components/controller/LobbyAuth.js +101 -15
- package/src/client/components/controller/Profile.js +95 -0
- package/src/client/components/model/Games.js +1 -1
- package/src/client/components/model/LobbyAuth.js +382 -115
- package/src/client/components/model/Profile.js +144 -0
- package/src/client/components/view/LobbyAuth.js +150 -62
- package/src/client/components/view/Profile.js +372 -0
- package/src/client/lib/authApi.js +106 -0
- package/src/client/lib/hostCaps.js +3 -2
- package/src/client/lib/pkce.js +23 -0
- package/src/client/lib/roomLink.js +1 -1
- package/src/client/lib/sessionSync.js +45 -0
- package/src/client/lib/signalingErrors.js +3 -3
- package/src/client/lib/turnstile.js +130 -0
- package/src/client/main.js +132 -26
- package/src/client/network/TokenRenewer.js +99 -0
- package/src/client/session/GuestSession.js +63 -17
- package/src/client/session/Membership.js +2 -1
- package/src/client/style.css +128 -16
- package/src/client/views/includes/lobby.pug +11 -6
- package/src/client/views/includes/lobbyAuth.pug +34 -16
- package/src/client/views/includes/profile.pug +73 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/authClient.js +55 -30
- package/src/config/chatMessages.js +1 -2
- package/src/config/env.js +2 -2
- package/src/config/lobby.js +45 -1
- package/src/config/master.js +8 -5
- package/src/config/wsports.js +4 -1
- package/src/devtools/builtinScenario.js +1 -1
- package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
- package/src/devtools/contract/rules/a4-vite-config.js +1 -1
- package/src/devtools/contract/rules/b8-system-messages.js +1 -1
- package/src/devtools/invariants.js +1 -1
- package/src/devtools/pluginLoader.js +1 -1
- package/src/host/GameCoreAdapter.js +0 -3
- package/src/host/HostGame.js +28 -0
- package/src/host/PortMachine.js +50 -1
- package/src/host/meta/SocketManager.js +0 -13
- package/src/host/meta/core/RoundManager.js +0 -19
- package/src/host/meta/modules/PlayerDataSync.js +29 -3
- package/src/host/meta/modules/chat/systemMessages.js +1 -4
- package/src/lib/loadGamePackage.js +1 -1
- package/src/lib/validators.js +8 -0
- package/src/standalone/index.js +1 -1
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Cloudflare Turnstile на регистрации: ленивая загрузка скрипта, один
|
|
2
|
+
// виджет, токен на одну отправку формы. Без сети в тестах: документ, окно и
|
|
3
|
+
// таймеры инъектируются.
|
|
4
|
+
|
|
5
|
+
// code: 'blocked' — скрипт или виджет недоступны (блокировщик, ошибка
|
|
6
|
+
// виджета); 'expired' — проверка истекла, не дождавшись игрока
|
|
7
|
+
function turnstileError(code) {
|
|
8
|
+
const error = new Error(`Turnstile ${code}`);
|
|
9
|
+
|
|
10
|
+
error.code = code;
|
|
11
|
+
|
|
12
|
+
return error;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const blockedError = () => turnstileError('blocked');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {Object} options
|
|
19
|
+
* @param {string} options.scriptUrl
|
|
20
|
+
* @param {number} options.loadTimeoutMs
|
|
21
|
+
* @param {string} options.action
|
|
22
|
+
* @param {Document} [options.doc]
|
|
23
|
+
* @param {Window} [options.win]
|
|
24
|
+
* @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
|
|
25
|
+
*/
|
|
26
|
+
export function createTurnstile({
|
|
27
|
+
scriptUrl,
|
|
28
|
+
loadTimeoutMs,
|
|
29
|
+
action,
|
|
30
|
+
doc = document,
|
|
31
|
+
win = window,
|
|
32
|
+
timers = globalThis,
|
|
33
|
+
}) {
|
|
34
|
+
let widgetId = null;
|
|
35
|
+
// виджет уже отдавал токен: перед следующим execute нужен reset
|
|
36
|
+
let used = false;
|
|
37
|
+
// текущий getToken: виджет создаётся один раз, а его callback'и должны
|
|
38
|
+
// завершать именно ожидающий вызов, не первый
|
|
39
|
+
let pending = null;
|
|
40
|
+
|
|
41
|
+
// скрипт подгружается при первом обращении; отказ (error, таймаут, нет
|
|
42
|
+
// win.turnstile после load) убирает элемент — Retry загрузит его заново
|
|
43
|
+
function loadScript() {
|
|
44
|
+
if (win.turnstile) {
|
|
45
|
+
return Promise.resolve();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const script = doc.createElement('script');
|
|
50
|
+
let timer = null;
|
|
51
|
+
|
|
52
|
+
const fail = () => {
|
|
53
|
+
timers.clearTimeout(timer);
|
|
54
|
+
script.remove();
|
|
55
|
+
reject(blockedError());
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
timer = timers.setTimeout(fail, loadTimeoutMs);
|
|
59
|
+
|
|
60
|
+
script.addEventListener('load', () => {
|
|
61
|
+
if (!win.turnstile) {
|
|
62
|
+
fail();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
timers.clearTimeout(timer);
|
|
67
|
+
resolve();
|
|
68
|
+
});
|
|
69
|
+
script.addEventListener('error', fail);
|
|
70
|
+
|
|
71
|
+
script.src = scriptUrl;
|
|
72
|
+
script.async = true;
|
|
73
|
+
doc.head.appendChild(script);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
// токен на одну отправку формы; отказ — Error с полем code = 'blocked'
|
|
79
|
+
async getToken(container, siteKey) {
|
|
80
|
+
await loadScript();
|
|
81
|
+
|
|
82
|
+
return new Promise((resolve, reject) => {
|
|
83
|
+
pending = { resolve, reject };
|
|
84
|
+
|
|
85
|
+
if (widgetId === null) {
|
|
86
|
+
widgetId = win.turnstile.render(container, {
|
|
87
|
+
sitekey: siteKey,
|
|
88
|
+
action,
|
|
89
|
+
execution: 'execute',
|
|
90
|
+
appearance: 'interaction-only',
|
|
91
|
+
callback: token => {
|
|
92
|
+
used = true;
|
|
93
|
+
pending?.resolve(token);
|
|
94
|
+
},
|
|
95
|
+
'error-callback': () => {
|
|
96
|
+
// после ошибки виджету нужен reset перед следующим execute
|
|
97
|
+
used = true;
|
|
98
|
+
pending?.reject(blockedError());
|
|
99
|
+
return true;
|
|
100
|
+
},
|
|
101
|
+
// проверка истекла, пока игрок не ответил: без этого getToken
|
|
102
|
+
// не завершился бы и форма осталась заблокированной
|
|
103
|
+
'timeout-callback': () => {
|
|
104
|
+
used = true;
|
|
105
|
+
pending?.reject(turnstileError('expired'));
|
|
106
|
+
},
|
|
107
|
+
'expired-callback': () => {
|
|
108
|
+
used = true;
|
|
109
|
+
pending?.reject(turnstileError('expired'));
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
} else if (used) {
|
|
113
|
+
win.turnstile.reset(widgetId);
|
|
114
|
+
used = false;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// срока ожидания нет: интерактивная проверка может ждать клика
|
|
118
|
+
win.turnstile.execute(container);
|
|
119
|
+
});
|
|
120
|
+
},
|
|
121
|
+
|
|
122
|
+
// токен израсходован сервером: следующая отправка получит новый
|
|
123
|
+
reset() {
|
|
124
|
+
if (widgetId !== null) {
|
|
125
|
+
win.turnstile.reset(widgetId);
|
|
126
|
+
used = false;
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
package/src/client/main.js
CHANGED
|
@@ -69,6 +69,14 @@ import SessionSupervisor, {
|
|
|
69
69
|
import FpsMeter from './lib/FpsMeter.js';
|
|
70
70
|
import HostUnloadGuard from './lib/hostUnloadGuard.js';
|
|
71
71
|
import { createPageReload } from './lib/pageReload.js';
|
|
72
|
+
import { createAuthApi } from './lib/authApi.js';
|
|
73
|
+
import {
|
|
74
|
+
decideSessionStorageChange,
|
|
75
|
+
decideStoredSession,
|
|
76
|
+
} from './lib/sessionSync.js';
|
|
77
|
+
import { createVerifier, challengeOf } from './lib/pkce.js';
|
|
78
|
+
import { createTurnstile } from './lib/turnstile.js';
|
|
79
|
+
import TokenRenewer from './network/TokenRenewer.js';
|
|
72
80
|
import {
|
|
73
81
|
isKickClose,
|
|
74
82
|
POLICY_CLOSE_INFORMS,
|
|
@@ -78,11 +86,14 @@ import LobbyModel from './components/model/Lobby.js';
|
|
|
78
86
|
import LobbyView from './components/view/Lobby.js';
|
|
79
87
|
import LobbyCtrl from './components/controller/Lobby.js';
|
|
80
88
|
import LobbyAuthModel from './components/model/LobbyAuth.js';
|
|
89
|
+
import ProfileModel from './components/model/Profile.js';
|
|
81
90
|
import LobbyAuthView from './components/view/LobbyAuth.js';
|
|
82
91
|
import LobbyAuthCtrl from './components/controller/LobbyAuth.js';
|
|
83
92
|
import GamesModel from './components/model/Games.js';
|
|
84
93
|
import GamesView from './components/view/Games.js';
|
|
85
94
|
import GamesCtrl from './components/controller/Games.js';
|
|
95
|
+
import ProfileView from './components/view/Profile.js';
|
|
96
|
+
import ProfileCtrl from './components/controller/Profile.js';
|
|
86
97
|
import { createRoomContext } from './session/roomContext.js';
|
|
87
98
|
import { createHostRoomPrep } from './session/hostRoomPrep.js';
|
|
88
99
|
import Membership from './session/Membership.js';
|
|
@@ -274,7 +285,6 @@ const PS_FIRST_SHOT_DATA = wsports.server.FIRST_SHOT_DATA;
|
|
|
274
285
|
const PS_SOUND_DATA = wsports.server.SOUND_DATA;
|
|
275
286
|
const PS_GAME_INFORM_DATA = wsports.server.GAME_INFORM_DATA;
|
|
276
287
|
const PS_TECH_INFORM_DATA = wsports.server.TECH_INFORM_DATA;
|
|
277
|
-
const PS_MISC = wsports.server.MISC;
|
|
278
288
|
const PS_PING = wsports.server.PING;
|
|
279
289
|
const PS_CLEAR = wsports.server.CLEAR;
|
|
280
290
|
const PS_CONSOLE = wsports.server.CONSOLE;
|
|
@@ -290,6 +300,7 @@ const PS_RESUME_RESULT = wsports.server.RESUME_RESULT;
|
|
|
290
300
|
// PC (client ports): порты получения данных от клиента
|
|
291
301
|
const PC_CONFIG_READY = wsports.client.CONFIG_READY;
|
|
292
302
|
const PC_AUTH_RESPONSE = wsports.client.AUTH_RESPONSE;
|
|
303
|
+
const PC_TOKEN_REFRESH = wsports.client.TOKEN_REFRESH;
|
|
293
304
|
const PC_MODULES_READY = wsports.client.MODULES_READY;
|
|
294
305
|
const PC_MAP_READY = wsports.client.MAP_READY;
|
|
295
306
|
const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
|
|
@@ -978,15 +989,6 @@ socketMethods[PS_TECH_INFORM_DATA] = data => {
|
|
|
978
989
|
}
|
|
979
990
|
};
|
|
980
991
|
|
|
981
|
-
// misc
|
|
982
|
-
socketMethods[PS_MISC] = data => {
|
|
983
|
-
const { key, value } = data;
|
|
984
|
-
|
|
985
|
-
if (key === 'localstorageNameReplace') {
|
|
986
|
-
localStorage['userName'] = value;
|
|
987
|
-
}
|
|
988
|
-
};
|
|
989
|
-
|
|
990
992
|
// ping
|
|
991
993
|
socketMethods[PS_PING] = pingId => {
|
|
992
994
|
sending(PC_PONG, pingId, false);
|
|
@@ -1909,7 +1911,7 @@ const guest = new GuestSession({
|
|
|
1909
1911
|
getPromotion: () => promotionFlow,
|
|
1910
1912
|
getSupervisor,
|
|
1911
1913
|
getToken,
|
|
1912
|
-
|
|
1914
|
+
refreshIdentity: () => lobbyAuthModel.refresh(),
|
|
1913
1915
|
config: lobbyConfig,
|
|
1914
1916
|
diagnostics,
|
|
1915
1917
|
ui: {
|
|
@@ -2471,8 +2473,8 @@ function initLobby() {
|
|
|
2471
2473
|
const games = new GamesCtrl(gamesModel, gamesView);
|
|
2472
2474
|
|
|
2473
2475
|
// кнопку «Moderation» лобби показывает роли admin (master-game-registry,
|
|
2474
|
-
// этап 4). Это подсказка интерфейсу:
|
|
2475
|
-
//
|
|
2476
|
+
// этап 4). Это подсказка интерфейсу: права админа auth-сервис проверяет
|
|
2477
|
+
// сам, по нику из VIMP_ADMIN_NICKS
|
|
2476
2478
|
games.setAdmin(lobbyAuthModel.getRole() === 'admin');
|
|
2477
2479
|
|
|
2478
2480
|
// журнал клиентских ошибок (plan/client-reports, этап 5): кнопку «Errors»
|
|
@@ -2493,6 +2495,17 @@ function initLobby() {
|
|
|
2493
2495
|
clientReportsView.setGames([...gamesById.values()]);
|
|
2494
2496
|
clientReports.setAdmin(lobbyAuthModel.getRole() === 'admin');
|
|
2495
2497
|
|
|
2498
|
+
// панель профиля: привязка аккаунта, выход, выгрузка данных, удаление
|
|
2499
|
+
const profileModel = new ProfileModel(lobbyConfig.profile, {
|
|
2500
|
+
auth: lobbyAuthModel,
|
|
2501
|
+
api: authApi,
|
|
2502
|
+
});
|
|
2503
|
+
const profileView = new ProfileView(profileModel, lobbyConfig.profile);
|
|
2504
|
+
const profile = new ProfileCtrl(profileModel, profileView);
|
|
2505
|
+
|
|
2506
|
+
// выход и удаление меняют личность вкладки — только перезагрузкой
|
|
2507
|
+
profile.publisher.on('left', () => reloadPage());
|
|
2508
|
+
|
|
2496
2509
|
// «Test»: манифест застейдженной версии кладётся в каталог вкладки, и
|
|
2497
2510
|
// админ поднимает по нему комнату обычной кнопкой Create server
|
|
2498
2511
|
games.publisher.on('staged', ({ manifest }) => {
|
|
@@ -2595,13 +2608,21 @@ function initLobby() {
|
|
|
2595
2608
|
});
|
|
2596
2609
|
|
|
2597
2610
|
lobby.open();
|
|
2611
|
+
|
|
2612
|
+
// вкладка вернулась от провайдера: итог привязки показывает панель
|
|
2613
|
+
const linkResult = profileModel.takeLinkResult();
|
|
2614
|
+
|
|
2615
|
+
if (linkResult) {
|
|
2616
|
+
profile.openWithLinkResult(linkResult);
|
|
2617
|
+
}
|
|
2598
2618
|
}
|
|
2599
2619
|
|
|
2600
|
-
//
|
|
2601
|
-
// только
|
|
2602
|
-
//
|
|
2603
|
-
// (OAuth
|
|
2620
|
+
// экран профиля: central auth-сервис выдаёт JWT по сессии устройства, лобби
|
|
2621
|
+
// открывается только когда у вкладки есть личность (профиль с глобальным
|
|
2622
|
+
// ником). Не зависит от сигнального сокета мастера — читает query string
|
|
2623
|
+
// (возврат с OAuth) и localStorage независимо от 'welcome'
|
|
2604
2624
|
let lobbyAuthModel = null;
|
|
2625
|
+
let authApi = null;
|
|
2605
2626
|
let lobbyAuthView = null;
|
|
2606
2627
|
|
|
2607
2628
|
let welcomeReceived = false;
|
|
@@ -2656,13 +2677,31 @@ function leaveRoomWith(message) {
|
|
|
2656
2677
|
}
|
|
2657
2678
|
}
|
|
2658
2679
|
|
|
2659
|
-
// точка 2 ветвления: лобби,
|
|
2680
|
+
// точка 2 ветвления: лобби, экран профиля и сигналинг живут только в lobby;
|
|
2660
2681
|
// solo и dedicated идут сразу к транспорту
|
|
2661
2682
|
if (isLobbyMode) {
|
|
2662
|
-
|
|
2683
|
+
authApi = createAuthApi({ serviceUrl: authClientConfig.serviceUrl });
|
|
2684
|
+
|
|
2685
|
+
lobbyAuthModel = new LobbyAuthModel(authClientConfig, {
|
|
2686
|
+
api: authApi,
|
|
2687
|
+
pkce: { createVerifier, challengeOf },
|
|
2688
|
+
isDev: isDevBuild,
|
|
2689
|
+
});
|
|
2663
2690
|
lobbyAuthView = new LobbyAuthView(lobbyAuthModel, authClientConfig);
|
|
2664
2691
|
|
|
2665
|
-
|
|
2692
|
+
// контроллер подписывается на вид сам
|
|
2693
|
+
new LobbyAuthCtrl(lobbyAuthModel, lobbyAuthView, {
|
|
2694
|
+
turnstile: createTurnstile(authClientConfig.turnstile),
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
// тихое продление identity-токена: вкладка остаётся в аккаунте, пока жива
|
|
2698
|
+
// сессия устройства
|
|
2699
|
+
const tokenRenewer = new TokenRenewer({
|
|
2700
|
+
getExpiresAt: getTokenExpiresAt,
|
|
2701
|
+
renew: () => lobbyAuthModel.refresh(),
|
|
2702
|
+
leadMs: authClientConfig.renew.leadMs,
|
|
2703
|
+
retryMs: authClientConfig.renew.retryMs,
|
|
2704
|
+
});
|
|
2666
2705
|
|
|
2667
2706
|
signaling.publisher.on('welcome', () => {
|
|
2668
2707
|
welcomeReceived = true;
|
|
@@ -2695,16 +2734,81 @@ if (isLobbyMode) {
|
|
|
2695
2734
|
handoff.armTokenHandoff();
|
|
2696
2735
|
}
|
|
2697
2736
|
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2737
|
+
tokenRenewer.arm();
|
|
2738
|
+
});
|
|
2739
|
+
|
|
2740
|
+
// свежий токен — мастеру (следующим сообщением сигналинга), хосту (порт
|
|
2741
|
+
// TOKEN_REFRESH) и таймерам роли хоста
|
|
2742
|
+
lobbyAuthModel.publisher.on('token-renewed', () => {
|
|
2743
|
+
tokenRenewer.arm();
|
|
2744
|
+
membership.armTokenCapsTimer();
|
|
2745
|
+
// вкладка в комнате возвращает мастеру canHost по новому сроку
|
|
2746
|
+
membership.sendUpdate();
|
|
2747
|
+
|
|
2748
|
+
if (hostRole.controller) {
|
|
2749
|
+
handoff.armTokenHandoff();
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
if (sessionStarted && supervisor) {
|
|
2753
|
+
sending(PC_TOKEN_REFRESH, { token: getToken() });
|
|
2702
2754
|
}
|
|
2703
2755
|
});
|
|
2704
2756
|
|
|
2705
|
-
//
|
|
2757
|
+
// личность вкладки меняется только перезагрузкой страницы; из комнаты в
|
|
2758
|
+
// лобби ведёт тоже только reloadPage(), поэтому в матче Welcome с
|
|
2759
|
+
// уведомлением покажет следующая загрузка
|
|
2760
|
+
lobbyAuthModel.publisher.on('session-lost', () => {
|
|
2761
|
+
lobbyAuthModel.setNotice('profileGone');
|
|
2762
|
+
|
|
2763
|
+
if (!roomCtx.roomId) {
|
|
2764
|
+
reloadPage();
|
|
2765
|
+
return;
|
|
2766
|
+
}
|
|
2767
|
+
|
|
2768
|
+
diagnostics.warn('engine.session.profileGone', {}, { source: 'client' });
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
// другая вкладка сменила личность браузера (регистрация, Restore, выход,
|
|
2772
|
+
// удаление): вкладка вне комнаты перечитывает её перезагрузкой
|
|
2773
|
+
window.addEventListener('storage', event => {
|
|
2774
|
+
if (event.storageArea !== localStorage) {
|
|
2775
|
+
return;
|
|
2776
|
+
}
|
|
2777
|
+
|
|
2778
|
+
const action = decideSessionStorageChange({
|
|
2779
|
+
event,
|
|
2780
|
+
sessionKey: authClientConfig.sessionStorageKey,
|
|
2781
|
+
currentSession: lobbyAuthModel.getSession(),
|
|
2782
|
+
inRoom: Boolean(roomCtx.roomId),
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
if (action === 'reload') {
|
|
2786
|
+
reloadPage();
|
|
2787
|
+
}
|
|
2788
|
+
});
|
|
2789
|
+
|
|
2790
|
+
// страница, восстановленная из bfcache (возврат кнопкой «Назад» от
|
|
2791
|
+
// провайдера OAuth), событий storage не получала: личность сверяется с
|
|
2792
|
+
// хранилищем заново
|
|
2793
|
+
window.addEventListener('pageshow', event => {
|
|
2794
|
+
if (!event.persisted) {
|
|
2795
|
+
return;
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
const action = decideStoredSession({
|
|
2799
|
+
storedSession: localStorage.getItem(authClientConfig.sessionStorageKey),
|
|
2800
|
+
currentSession: lobbyAuthModel.getSession(),
|
|
2801
|
+
inRoom: Boolean(roomCtx.roomId),
|
|
2802
|
+
});
|
|
2803
|
+
|
|
2804
|
+
if (action === 'reload') {
|
|
2805
|
+
reloadPage();
|
|
2806
|
+
}
|
|
2807
|
+
});
|
|
2808
|
+
|
|
2809
|
+
// возврат с OAuth и dev-сессия прочитаны — чистим только query, hash
|
|
2706
2810
|
// (маршрут #/<gameId>[/<roomId>]) остаётся
|
|
2707
|
-
if (
|
|
2811
|
+
if (lobbyAuthModel.takeUrlParams(window.location.search)) {
|
|
2708
2812
|
window.history.replaceState(
|
|
2709
2813
|
null,
|
|
2710
2814
|
'',
|
|
@@ -2712,6 +2816,8 @@ if (isLobbyMode) {
|
|
|
2712
2816
|
);
|
|
2713
2817
|
}
|
|
2714
2818
|
|
|
2819
|
+
lobbyAuthModel.boot();
|
|
2820
|
+
|
|
2715
2821
|
window.addEventListener('hashchange', () => route.handleHashChange());
|
|
2716
2822
|
|
|
2717
2823
|
signaling.connect();
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Тихое продление identity-токена: за leadMs до exp запрашивает свежий у
|
|
2
|
+
// сессии устройства. Сбой сети — повтор через retryMs без ограничения числа
|
|
3
|
+
// попыток: сеть вернётся — токен продлится. Запуски идут не чаще раза в
|
|
4
|
+
// retryMs, даже если срок остаётся в окне продления (цикла не будет).
|
|
5
|
+
//
|
|
6
|
+
// Без DOM: срок, продление и таймеры инъектирует владелец (client/main.js).
|
|
7
|
+
|
|
8
|
+
export default class TokenRenewer {
|
|
9
|
+
/**
|
|
10
|
+
* @param {Object} options
|
|
11
|
+
* @param {Function} options.getExpiresAt - () → мс эпохи | null.
|
|
12
|
+
* @param {Function} options.renew - () → Promise<'ok' | 'retry' | 'lost'>.
|
|
13
|
+
* @param {number} options.leadMs
|
|
14
|
+
* @param {number} options.retryMs
|
|
15
|
+
* @param {Function} [options.now]
|
|
16
|
+
* @param {Object} [options.timers] - { setTimeout, clearTimeout } (тесты).
|
|
17
|
+
*/
|
|
18
|
+
constructor({
|
|
19
|
+
getExpiresAt,
|
|
20
|
+
renew,
|
|
21
|
+
leadMs,
|
|
22
|
+
retryMs,
|
|
23
|
+
now = () => Date.now(),
|
|
24
|
+
timers = globalThis,
|
|
25
|
+
}) {
|
|
26
|
+
this._getExpiresAt = getExpiresAt;
|
|
27
|
+
this._renew = renew;
|
|
28
|
+
this._leadMs = leadMs;
|
|
29
|
+
this._retryMs = retryMs;
|
|
30
|
+
this._now = now;
|
|
31
|
+
this._timers = timers;
|
|
32
|
+
this._timer = null;
|
|
33
|
+
this._lastFireAt = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// таймер на expiresAt - leadMs, но не раньше «сейчас»; срок неизвестен —
|
|
37
|
+
// таймера нет. Повторный arm заменяет прежний таймер
|
|
38
|
+
arm() {
|
|
39
|
+
this._schedule(this._delayToLead());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
cancel() {
|
|
43
|
+
if (this._timer !== null) {
|
|
44
|
+
this._timers.clearTimeout(this._timer);
|
|
45
|
+
this._timer = null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
_delayToLead() {
|
|
50
|
+
const expiresAt = this._getExpiresAt();
|
|
51
|
+
|
|
52
|
+
if (expiresAt === null) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const now = this._now();
|
|
57
|
+
// не раньше retryMs после предыдущего запуска: срок, который и со
|
|
58
|
+
// свежим токеном остаётся в окне продления, не должен завести цикл.
|
|
59
|
+
// Сверху порог ограничен тем же retryMs: часы устройства могли перевести
|
|
60
|
+
// назад, и разность с прошлым запуском уже ничего не значит
|
|
61
|
+
const floor =
|
|
62
|
+
this._lastFireAt === null
|
|
63
|
+
? 0
|
|
64
|
+
: Math.min(this._retryMs, this._lastFireAt + this._retryMs - now);
|
|
65
|
+
|
|
66
|
+
return Math.max(0, floor, expiresAt - this._leadMs - now);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_schedule(delay) {
|
|
70
|
+
this.cancel();
|
|
71
|
+
|
|
72
|
+
if (delay === null) {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
this._timer = this._timers.setTimeout(() => this._fire(), delay);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async _fire() {
|
|
80
|
+
this._timer = null;
|
|
81
|
+
this._lastFireAt = this._now();
|
|
82
|
+
|
|
83
|
+
let result;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
result = await this._renew();
|
|
87
|
+
} catch {
|
|
88
|
+
// неожиданный сбой не должен обрывать цепочку продления
|
|
89
|
+
result = 'retry';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (result === 'ok') {
|
|
93
|
+
this.arm();
|
|
94
|
+
} else if (result === 'retry') {
|
|
95
|
+
this._schedule(this._retryMs);
|
|
96
|
+
}
|
|
97
|
+
// 'lost': таймер не взводится, потерю объявляет модель
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -21,6 +21,9 @@ import WebRtcManager from '../network/WebRtcManager.js';
|
|
|
21
21
|
// Без DOM: сигналинг, роли, супервизор, UI-действия и фабрики инъектирует
|
|
22
22
|
// сборщик (client/main.js).
|
|
23
23
|
|
|
24
|
+
// минимум между продлениями входа по одному виду запроса
|
|
25
|
+
const RENEW_RETRY_MIN_MS = 60000;
|
|
26
|
+
|
|
24
27
|
export default class GuestSession {
|
|
25
28
|
/**
|
|
26
29
|
* @param {Object} deps
|
|
@@ -32,7 +35,8 @@ export default class GuestSession {
|
|
|
32
35
|
* @param {Function} deps.getPromotion - () → PromotionFlow.
|
|
33
36
|
* @param {Function} deps.getSupervisor - () → SessionSupervisor | null.
|
|
34
37
|
* @param {Function} deps.getToken - () → identity-токен.
|
|
35
|
-
* @param {Function} deps.
|
|
38
|
+
* @param {Function} deps.refreshIdentity - () → Promise<'ok'|'retry'|'lost'>,
|
|
39
|
+
* продление identity-токена по сессии устройства.
|
|
36
40
|
* @param {Object} deps.config - lobby-конфиг (webrtc, reconnect).
|
|
37
41
|
* @param {Object} deps.diagnostics
|
|
38
42
|
* @param {Object} deps.ui - { ensureWebRtc() → bool, showRoomLink(roomId),
|
|
@@ -51,7 +55,7 @@ export default class GuestSession {
|
|
|
51
55
|
getPromotion,
|
|
52
56
|
getSupervisor,
|
|
53
57
|
getToken,
|
|
54
|
-
|
|
58
|
+
refreshIdentity,
|
|
55
59
|
config,
|
|
56
60
|
diagnostics,
|
|
57
61
|
ui,
|
|
@@ -67,7 +71,7 @@ export default class GuestSession {
|
|
|
67
71
|
this._getPromotion = getPromotion;
|
|
68
72
|
this._getSupervisor = getSupervisor;
|
|
69
73
|
this._getToken = getToken;
|
|
70
|
-
this.
|
|
74
|
+
this._refreshIdentity = refreshIdentity;
|
|
71
75
|
this._config = config;
|
|
72
76
|
this._diagnostics = diagnostics;
|
|
73
77
|
this._ui = ui;
|
|
@@ -83,6 +87,8 @@ export default class GuestSession {
|
|
|
83
87
|
// открытое у гостя голосование «Change host» ({ roomId, voteId }) —
|
|
84
88
|
// ответ окна уходит мастеру с этим voteId
|
|
85
89
|
this._hostVote = null;
|
|
90
|
+
// вид запроса (msg.re) → время последнего продления (защита от цикла)
|
|
91
|
+
this._renewAttempts = new Map();
|
|
86
92
|
}
|
|
87
93
|
|
|
88
94
|
get hostVote() {
|
|
@@ -323,22 +329,15 @@ export default class GuestSession {
|
|
|
323
329
|
if (action === 'abandonPromotion') {
|
|
324
330
|
this._getPromotion().abandon({ code, report: true });
|
|
325
331
|
} else if (action === 'keepPlaying') {
|
|
326
|
-
// P2P-матчу токен не
|
|
327
|
-
// сообщения — только кодами, тексты в играх)
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
{ re: msg.re },
|
|
331
|
-
{ source: 'client' },
|
|
332
|
-
);
|
|
332
|
+
// P2P-матчу токен не нужен, без нового текста в UI (системные
|
|
333
|
+
// сообщения — только кодами, тексты в играх). Членство на мастере
|
|
334
|
+
// вернёт повторный join_room после продления; о неудаче продления
|
|
335
|
+
// пишет _renewAndRetry
|
|
333
336
|
this._membership.stopJoinRetry();
|
|
334
|
-
}
|
|
335
|
-
this._logout();
|
|
337
|
+
}
|
|
336
338
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
'Your session has expired — please sign in again.',
|
|
340
|
-
);
|
|
341
|
-
}
|
|
339
|
+
if (action !== 'abandonPromotion') {
|
|
340
|
+
this._renewAndRetry(msg);
|
|
342
341
|
}
|
|
343
342
|
break;
|
|
344
343
|
}
|
|
@@ -386,6 +385,53 @@ export default class GuestSession {
|
|
|
386
385
|
}
|
|
387
386
|
}
|
|
388
387
|
|
|
388
|
+
// продление входа и повтор отклонённого запроса. Не чаще раза в
|
|
389
|
+
// RENEW_RETRY_MIN_MS на вид запроса: мастер, отвергающий и свежий токен,
|
|
390
|
+
// не должен завести цикл
|
|
391
|
+
async _renewAndRetry(msg) {
|
|
392
|
+
const now = this._now();
|
|
393
|
+
const last = this._renewAttempts.get(msg.re);
|
|
394
|
+
|
|
395
|
+
if (last !== undefined && now - last < RENEW_RETRY_MIN_MS) {
|
|
396
|
+
this._warnTokenExpired(msg);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
this._renewAttempts.set(msg.re, now);
|
|
401
|
+
|
|
402
|
+
const result = await this._refreshIdentity();
|
|
403
|
+
|
|
404
|
+
if (result === 'ok') {
|
|
405
|
+
if (msg.re === 'join_room') {
|
|
406
|
+
this._membership.sendJoinRoom();
|
|
407
|
+
} else if (msg.re === 'register_host' || msg.re === 'reclaim_host') {
|
|
408
|
+
this._hostRole.reRegister();
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// хост, не продливший вход, не удержит комнату
|
|
414
|
+
if (
|
|
415
|
+
this._ctx.roomId &&
|
|
416
|
+
(msg.re === 'register_host' || msg.re === 'reclaim_host')
|
|
417
|
+
) {
|
|
418
|
+
this._ui.leaveRoomWith(
|
|
419
|
+
'Your session could not be renewed — please reload the page.',
|
|
420
|
+
);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
this._warnTokenExpired(msg);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
_warnTokenExpired(msg) {
|
|
428
|
+
this._diagnostics.warn(
|
|
429
|
+
'engine.session.tokenExpired',
|
|
430
|
+
{ re: msg.re },
|
|
431
|
+
{ source: 'client' },
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
389
435
|
_ownRoom(msg) {
|
|
390
436
|
return Boolean(this._ctx.roomId) && msg.roomId === this._ctx.roomId;
|
|
391
437
|
}
|
|
@@ -85,7 +85,8 @@ export default class Membership {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
// хватит ли срока входа на роль хоста: register_host/reclaim_host
|
|
88
|
-
// предъявляют токен мастеру посреди
|
|
88
|
+
// предъявляют токен мастеру посреди матча. Страховка на случай, когда
|
|
89
|
+
// тихое продление токена не удаётся
|
|
89
90
|
tokenAllowsHostRole() {
|
|
90
91
|
return tokenAllowsHosting(
|
|
91
92
|
this._getTokenExpiresAt(),
|