vimp-engine 0.37.1 → 0.38.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 (44) hide show
  1. package/package.json +1 -1
  2. package/src/client/boot.js +1 -1
  3. package/src/client/components/controller/LobbyAuth.js +97 -15
  4. package/src/client/components/controller/Profile.js +85 -0
  5. package/src/client/components/model/Games.js +1 -1
  6. package/src/client/components/model/LobbyAuth.js +344 -115
  7. package/src/client/components/model/Profile.js +142 -0
  8. package/src/client/components/view/LobbyAuth.js +150 -62
  9. package/src/client/components/view/Profile.js +373 -0
  10. package/src/client/lib/authApi.js +97 -0
  11. package/src/client/lib/pkce.js +23 -0
  12. package/src/client/lib/roomLink.js +1 -1
  13. package/src/client/lib/signalingErrors.js +3 -3
  14. package/src/client/lib/turnstile.js +130 -0
  15. package/src/client/main.js +89 -24
  16. package/src/client/network/TokenRenewer.js +90 -0
  17. package/src/client/session/GuestSession.js +63 -17
  18. package/src/client/style.css +119 -16
  19. package/src/client/views/includes/lobby.pug +11 -6
  20. package/src/client/views/includes/lobbyAuth.pug +34 -16
  21. package/src/client/views/includes/profile.pug +73 -0
  22. package/src/client/views/index.pug +1 -0
  23. package/src/config/authClient.js +54 -30
  24. package/src/config/chatMessages.js +1 -2
  25. package/src/config/env.js +2 -2
  26. package/src/config/lobby.js +45 -0
  27. package/src/config/master.js +8 -5
  28. package/src/config/wsports.js +4 -1
  29. package/src/devtools/builtinScenario.js +1 -1
  30. package/src/devtools/contract/rules/a2-package-scripts.js +1 -1
  31. package/src/devtools/contract/rules/a4-vite-config.js +1 -1
  32. package/src/devtools/contract/rules/b8-system-messages.js +1 -1
  33. package/src/devtools/invariants.js +1 -1
  34. package/src/devtools/pluginLoader.js +1 -1
  35. package/src/host/GameCoreAdapter.js +0 -3
  36. package/src/host/HostGame.js +28 -0
  37. package/src/host/PortMachine.js +50 -1
  38. package/src/host/meta/SocketManager.js +0 -13
  39. package/src/host/meta/core/RoundManager.js +0 -19
  40. package/src/host/meta/modules/PlayerDataSync.js +29 -3
  41. package/src/host/meta/modules/chat/systemMessages.js +1 -4
  42. package/src/lib/loadGamePackage.js +1 -1
  43. package/src/lib/validators.js +8 -0
  44. package/src/standalone/index.js +1 -1
@@ -0,0 +1,97 @@
1
+ // REST-клиент auth-сервиса (packages/auth): регистрация, сессия, OAuth,
2
+ // управление профилем. Без DOM; fetch инъектируется (тесты).
3
+ //
4
+ // Каждый метод возвращает { ok: true, status, data } или
5
+ // { ok: false, status, error }: error — поле `error` тела ответа ('unknown',
6
+ // если его нет), 'network' — fetch бросил исключение (status 0).
7
+
8
+ const JSON_HEADERS = { 'Content-Type': 'application/json' };
9
+
10
+ /**
11
+ * @param {Object} options
12
+ * @param {string} options.serviceUrl - базовый URL auth-сервиса.
13
+ * @param {Function} [options.fetchImpl]
14
+ */
15
+ export function createAuthApi({
16
+ serviceUrl,
17
+ fetchImpl = (...args) => fetch(...args),
18
+ }) {
19
+ // куки не используются: личность — заголовок Authorization
20
+ async function request(method, path, { session, body } = {}) {
21
+ const headers = {};
22
+
23
+ if (body !== undefined) {
24
+ Object.assign(headers, JSON_HEADERS);
25
+ }
26
+
27
+ if (session) {
28
+ headers.Authorization = `Session ${session}`;
29
+ }
30
+
31
+ const init = { method, headers };
32
+
33
+ if (body !== undefined) {
34
+ init.body = JSON.stringify(body);
35
+ }
36
+
37
+ let res;
38
+
39
+ try {
40
+ res = await fetchImpl(`${serviceUrl}${path}`, init);
41
+ } catch {
42
+ return { ok: false, status: 0, error: 'network' };
43
+ }
44
+
45
+ const data = res.status === 204 ? null : await res.json().catch(() => null);
46
+
47
+ if (!res.ok) {
48
+ return {
49
+ ok: false,
50
+ status: res.status,
51
+ error: (data && data.error) || 'unknown',
52
+ };
53
+ }
54
+
55
+ return { ok: true, status: res.status, data };
56
+ }
57
+
58
+ return {
59
+ clientConfig: () => request('GET', '/client-config'),
60
+
61
+ register: ({ nick, turnstileToken, termsVersion }) =>
62
+ request('POST', '/register', {
63
+ body: { nick, turnstileToken, termsVersion },
64
+ }),
65
+
66
+ token: session => request('POST', '/token', { session }),
67
+
68
+ logout: session => request('POST', '/logout', { session }),
69
+
70
+ restore: ({ code, verifier, termsVersion }) =>
71
+ request('POST', '/session/restore', {
72
+ body: { code, verifier, termsVersion },
73
+ }),
74
+
75
+ linkOauth: (session, { code, verifier, consentVersion }) =>
76
+ request('POST', '/account/oauth', {
77
+ session,
78
+ body: { code, verifier, consentVersion },
79
+ }),
80
+
81
+ unlinkOauth: (session, provider) =>
82
+ request('DELETE', `/account/oauth/${encodeURIComponent(provider)}`, {
83
+ session,
84
+ }),
85
+
86
+ deleteAccount: (session, confirmNick) =>
87
+ request('DELETE', '/account', { session, body: { confirmNick } }),
88
+
89
+ exportData: session => request('GET', '/account/export', { session }),
90
+
91
+ // навигация вкладки, не fetch: URL отдаётся вызывающему
92
+ oauthStartUrl: (provider, { mode, returnUrl, challenge }) =>
93
+ `${serviceUrl}/oauth/${provider}/start?mode=${encodeURIComponent(mode)}` +
94
+ `&returnUrl=${encodeURIComponent(returnUrl)}` +
95
+ `&challenge=${encodeURIComponent(challenge)}`,
96
+ };
97
+ }
@@ -0,0 +1,23 @@
1
+ // PKCE-подобная привязка результата OAuth к начавшей вкладке: verifier
2
+ // остаётся в её sessionStorage, на auth уходит только challenge.
3
+
4
+ const toBase64Url = bytes =>
5
+ btoa(String.fromCharCode(...bytes))
6
+ .replace(/\+/g, '-')
7
+ .replace(/\//g, '_')
8
+ .replace(/=+$/, '');
9
+
10
+ // 32 случайных байта → base64url (43 символа)
11
+ export function createVerifier(cryptoImpl = crypto) {
12
+ return toBase64Url(cryptoImpl.getRandomValues(new Uint8Array(32)));
13
+ }
14
+
15
+ // base64url(sha256(verifier)); Web Crypto, поэтому асинхронно
16
+ export async function challengeOf(verifier, cryptoImpl = crypto) {
17
+ const digest = await cryptoImpl.subtle.digest(
18
+ 'SHA-256',
19
+ new TextEncoder().encode(verifier),
20
+ );
21
+
22
+ return toBase64Url(new Uint8Array(digest));
23
+ }
@@ -5,7 +5,7 @@
5
5
  // #/<gameId>/<roomId> — прямой вход в комнату
6
6
  //
7
7
  // Маршрут живёт в hash: страница одна (SPA), сервер про маршруты не знает, а
8
- // OAuth-возврат hash сохраняет (returnUrl LobbyAuthModel.loginUrl). Решения
8
+ // OAuth-возврат hash сохраняет (returnUrl LobbyAuthModel.restoreUrl). Решения
9
9
  // «что делать с маршрутом» и «куда уходить из комнаты» — чистые функции:
10
10
  // main.js исполняется при импорте, и проверить ветвление внутри него нечем.
11
11
 
@@ -52,13 +52,13 @@ export function decideUnknownRoom({
52
52
  }
53
53
 
54
54
  /**
55
- * Что делать с error {code: 'invalidToken'} мастера (истёкший вход).
55
+ * Что делать с error {code: 'invalidToken'} мастера (токен отклонён).
56
56
  * @param {Object} ctx
57
57
  * @param {Object} ctx.msg - сообщение ошибки ({ code, re, roomId }).
58
58
  * @param {boolean} ctx.promoting - вкладка занимает комнату (hostPromotion).
59
59
  * @param {boolean} ctx.inRoom - вкладка в комнате.
60
60
  * @param {string|null} ctx.sessionState - состояние SessionSupervisor.
61
- * @returns {'abandonPromotion'|'keepPlaying'|'logoutAndLeave'|'logout'}
61
+ * @returns {'abandonPromotion'|'keepPlaying'|'renewAndRetry'}
62
62
  */
63
63
  export function decideInvalidToken({ msg, promoting, inRoom, sessionState }) {
64
64
  if (msg.re === 'register_host' && promoting) {
@@ -70,5 +70,5 @@ export function decideInvalidToken({ msg, promoting, inRoom, sessionState }) {
70
70
  return 'keepPlaying';
71
71
  }
72
72
 
73
- return inRoom ? 'logoutAndLeave' : 'logout';
73
+ return 'renewAndRetry';
74
74
  }
@@ -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
+ }
@@ -69,6 +69,10 @@ 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 { createVerifier, challengeOf } from './lib/pkce.js';
74
+ import { createTurnstile } from './lib/turnstile.js';
75
+ import TokenRenewer from './network/TokenRenewer.js';
72
76
  import {
73
77
  isKickClose,
74
78
  POLICY_CLOSE_INFORMS,
@@ -78,11 +82,14 @@ import LobbyModel from './components/model/Lobby.js';
78
82
  import LobbyView from './components/view/Lobby.js';
79
83
  import LobbyCtrl from './components/controller/Lobby.js';
80
84
  import LobbyAuthModel from './components/model/LobbyAuth.js';
85
+ import ProfileModel from './components/model/Profile.js';
81
86
  import LobbyAuthView from './components/view/LobbyAuth.js';
82
87
  import LobbyAuthCtrl from './components/controller/LobbyAuth.js';
83
88
  import GamesModel from './components/model/Games.js';
84
89
  import GamesView from './components/view/Games.js';
85
90
  import GamesCtrl from './components/controller/Games.js';
91
+ import ProfileView from './components/view/Profile.js';
92
+ import ProfileCtrl from './components/controller/Profile.js';
86
93
  import { createRoomContext } from './session/roomContext.js';
87
94
  import { createHostRoomPrep } from './session/hostRoomPrep.js';
88
95
  import Membership from './session/Membership.js';
@@ -274,7 +281,6 @@ const PS_FIRST_SHOT_DATA = wsports.server.FIRST_SHOT_DATA;
274
281
  const PS_SOUND_DATA = wsports.server.SOUND_DATA;
275
282
  const PS_GAME_INFORM_DATA = wsports.server.GAME_INFORM_DATA;
276
283
  const PS_TECH_INFORM_DATA = wsports.server.TECH_INFORM_DATA;
277
- const PS_MISC = wsports.server.MISC;
278
284
  const PS_PING = wsports.server.PING;
279
285
  const PS_CLEAR = wsports.server.CLEAR;
280
286
  const PS_CONSOLE = wsports.server.CONSOLE;
@@ -290,6 +296,7 @@ const PS_RESUME_RESULT = wsports.server.RESUME_RESULT;
290
296
  // PC (client ports): порты получения данных от клиента
291
297
  const PC_CONFIG_READY = wsports.client.CONFIG_READY;
292
298
  const PC_AUTH_RESPONSE = wsports.client.AUTH_RESPONSE;
299
+ const PC_TOKEN_REFRESH = wsports.client.TOKEN_REFRESH;
293
300
  const PC_MODULES_READY = wsports.client.MODULES_READY;
294
301
  const PC_MAP_READY = wsports.client.MAP_READY;
295
302
  const PC_FIRST_SHOT_READY = wsports.client.FIRST_SHOT_READY;
@@ -978,15 +985,6 @@ socketMethods[PS_TECH_INFORM_DATA] = data => {
978
985
  }
979
986
  };
980
987
 
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
988
  // ping
991
989
  socketMethods[PS_PING] = pingId => {
992
990
  sending(PC_PONG, pingId, false);
@@ -1909,7 +1907,7 @@ const guest = new GuestSession({
1909
1907
  getPromotion: () => promotionFlow,
1910
1908
  getSupervisor,
1911
1909
  getToken,
1912
- logout: () => lobbyAuthModel.logout(),
1910
+ refreshIdentity: () => lobbyAuthModel.refresh(),
1913
1911
  config: lobbyConfig,
1914
1912
  diagnostics,
1915
1913
  ui: {
@@ -2493,6 +2491,17 @@ function initLobby() {
2493
2491
  clientReportsView.setGames([...gamesById.values()]);
2494
2492
  clientReports.setAdmin(lobbyAuthModel.getRole() === 'admin');
2495
2493
 
2494
+ // панель профиля: привязка аккаунта, выход, выгрузка данных, удаление
2495
+ const profileModel = new ProfileModel(lobbyConfig.profile, {
2496
+ auth: lobbyAuthModel,
2497
+ api: authApi,
2498
+ });
2499
+ const profileView = new ProfileView(profileModel, lobbyConfig.profile);
2500
+ const profile = new ProfileCtrl(profileModel, profileView);
2501
+
2502
+ // выход и удаление меняют личность вкладки — только перезагрузкой
2503
+ profile.publisher.on('left', () => reloadPage());
2504
+
2496
2505
  // «Test»: манифест застейдженной версии кладётся в каталог вкладки, и
2497
2506
  // админ поднимает по нему комнату обычной кнопкой Create server
2498
2507
  games.publisher.on('staged', ({ manifest }) => {
@@ -2595,13 +2604,21 @@ function initLobby() {
2595
2604
  });
2596
2605
 
2597
2606
  lobby.open();
2607
+
2608
+ // вкладка вернулась от провайдера: итог привязки показывает панель
2609
+ const linkResult = profileModel.takeLinkResult();
2610
+
2611
+ if (linkResult) {
2612
+ profile.openWithLinkResult(linkResult);
2613
+ }
2598
2614
  }
2599
2615
 
2600
- // логин лобби (Этап B2): central auth-сервис выдаёт JWT, лобби открывается
2601
- // только после успешной авторизации (глобальный ник вместо свободного ввода
2602
- // в игре). Не зависит от сигнального сокета мастера — читает query string
2603
- // (OAuth-редирект) и localStorage независимо от 'welcome'
2616
+ // экран профиля: central auth-сервис выдаёт JWT по сессии устройства, лобби
2617
+ // открывается только когда у вкладки есть личность (профиль с глобальным
2618
+ // ником). Не зависит от сигнального сокета мастера — читает query string
2619
+ // (возврат с OAuth) и localStorage независимо от 'welcome'
2604
2620
  let lobbyAuthModel = null;
2621
+ let authApi = null;
2605
2622
  let lobbyAuthView = null;
2606
2623
 
2607
2624
  let welcomeReceived = false;
@@ -2656,13 +2673,32 @@ function leaveRoomWith(message) {
2656
2673
  }
2657
2674
  }
2658
2675
 
2659
- // точка 2 ветвления: лобби, OAuth-гейт и сигналинг живут только в lobby;
2676
+ // точка 2 ветвления: лобби, экран профиля и сигналинг живут только в lobby;
2660
2677
  // solo и dedicated идут сразу к транспорту
2661
2678
  if (isLobbyMode) {
2662
- lobbyAuthModel = new LobbyAuthModel(authClientConfig);
2679
+ authApi = createAuthApi({ serviceUrl: authClientConfig.serviceUrl });
2680
+
2681
+ lobbyAuthModel = new LobbyAuthModel(authClientConfig, {
2682
+ api: authApi,
2683
+ pkce: { createVerifier, challengeOf },
2684
+ isDev: isDevBuild,
2685
+ });
2663
2686
  lobbyAuthView = new LobbyAuthView(lobbyAuthModel, authClientConfig);
2664
2687
 
2665
- const lobbyAuthCtrl = new LobbyAuthCtrl(lobbyAuthModel, lobbyAuthView);
2688
+ // контроллер подписывается на вид сам
2689
+ new LobbyAuthCtrl(lobbyAuthModel, lobbyAuthView, {
2690
+ turnstile: createTurnstile(authClientConfig.turnstile),
2691
+ });
2692
+
2693
+ // тихое продление identity-токена: вкладка остаётся в аккаунте, пока жива
2694
+ // сессия устройства
2695
+ const tokenRenewer = new TokenRenewer({
2696
+ getExpiresAt: getTokenExpiresAt,
2697
+ renew: () => lobbyAuthModel.refresh(),
2698
+ onLost: () => {},
2699
+ leadMs: authClientConfig.renew.leadMs,
2700
+ retryMs: authClientConfig.renew.retryMs,
2701
+ });
2666
2702
 
2667
2703
  signaling.publisher.on('welcome', () => {
2668
2704
  welcomeReceived = true;
@@ -2695,16 +2731,43 @@ if (isLobbyMode) {
2695
2731
  handoff.armTokenHandoff();
2696
2732
  }
2697
2733
 
2698
- // повторный вход после logout: маршрут уже разобран, а логин-гейт
2699
- // спрятал лобби — вернуть его, если вкладка не в комнате
2700
- if (route.booted && !roomCtx.entered) {
2701
- lobby?.open();
2734
+ tokenRenewer.arm();
2735
+ });
2736
+
2737
+ // свежий токен — мастеру (следующим сообщением сигналинга), хосту (порт
2738
+ // TOKEN_REFRESH) и таймерам роли хоста
2739
+ lobbyAuthModel.publisher.on('token-renewed', () => {
2740
+ tokenRenewer.arm();
2741
+ membership.armTokenCapsTimer();
2742
+ // вкладка в комнате возвращает мастеру canHost по новому сроку
2743
+ membership.sendUpdate();
2744
+
2745
+ if (hostRole.controller) {
2746
+ handoff.armTokenHandoff();
2747
+ }
2748
+
2749
+ if (sessionStarted && supervisor) {
2750
+ sending(PC_TOKEN_REFRESH, { token: getToken() });
2702
2751
  }
2703
2752
  });
2704
2753
 
2705
- // OAuth-возврат: токены из query прочитаны — чистим только query, hash
2754
+ // личность вкладки меняется только перезагрузкой страницы; из комнаты в
2755
+ // лобби ведёт тоже только reloadPage(), поэтому в матче Welcome с
2756
+ // уведомлением покажет следующая загрузка
2757
+ lobbyAuthModel.publisher.on('session-lost', () => {
2758
+ lobbyAuthModel.setNotice('profileGone');
2759
+
2760
+ if (!roomCtx.roomId) {
2761
+ reloadPage();
2762
+ return;
2763
+ }
2764
+
2765
+ diagnostics.warn('engine.session.profileGone', {}, { source: 'client' });
2766
+ });
2767
+
2768
+ // возврат с OAuth и dev-сессия прочитаны — чистим только query, hash
2706
2769
  // (маршрут #/<gameId>[/<roomId>]) остаётся
2707
- if (lobbyAuthCtrl.init(window.location.search)) {
2770
+ if (lobbyAuthModel.takeUrlParams(window.location.search)) {
2708
2771
  window.history.replaceState(
2709
2772
  null,
2710
2773
  '',
@@ -2712,6 +2775,8 @@ if (isLobbyMode) {
2712
2775
  );
2713
2776
  }
2714
2777
 
2778
+ lobbyAuthModel.boot();
2779
+
2715
2780
  window.addEventListener('hashchange', () => route.handleHashChange());
2716
2781
 
2717
2782
  signaling.connect();
@@ -0,0 +1,90 @@
1
+ // Тихое продление identity-токена: за leadMs до exp запрашивает свежий у
2
+ // сессии устройства. Сбой сети — повтор через retryMs без ограничения числа
3
+ // попыток: сеть вернётся — токен продлится.
4
+ //
5
+ // Без DOM: срок, продление и таймеры инъектирует владелец (client/main.js).
6
+
7
+ export default class TokenRenewer {
8
+ /**
9
+ * @param {Object} options
10
+ * @param {Function} options.getExpiresAt - () → мс эпохи | null.
11
+ * @param {Function} options.renew - () → Promise<'ok' | 'retry' | 'lost'>.
12
+ * @param {Function} options.onLost - сессия потеряна, продлевать нечем.
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
+ onLost,
22
+ leadMs,
23
+ retryMs,
24
+ now = () => Date.now(),
25
+ timers = globalThis,
26
+ }) {
27
+ this._getExpiresAt = getExpiresAt;
28
+ this._renew = renew;
29
+ this._onLost = onLost;
30
+ this._leadMs = leadMs;
31
+ this._retryMs = retryMs;
32
+ this._now = now;
33
+ this._timers = timers;
34
+ this._timer = null;
35
+ }
36
+
37
+ // таймер на expiresAt - leadMs, но не раньше «сейчас»; срок неизвестен —
38
+ // таймера нет. Повторный arm заменяет прежний таймер
39
+ arm() {
40
+ this._schedule(this._delayToLead());
41
+ }
42
+
43
+ cancel() {
44
+ if (this._timer !== null) {
45
+ this._timers.clearTimeout(this._timer);
46
+ this._timer = null;
47
+ }
48
+ }
49
+
50
+ _delayToLead() {
51
+ const expiresAt = this._getExpiresAt();
52
+
53
+ if (expiresAt === null) {
54
+ return null;
55
+ }
56
+
57
+ return Math.max(0, expiresAt - this._leadMs - this._now());
58
+ }
59
+
60
+ _schedule(delay) {
61
+ this.cancel();
62
+
63
+ if (delay === null) {
64
+ return;
65
+ }
66
+
67
+ this._timer = this._timers.setTimeout(() => this._fire(), delay);
68
+ }
69
+
70
+ async _fire() {
71
+ this._timer = null;
72
+
73
+ let result;
74
+
75
+ try {
76
+ result = await this._renew();
77
+ } catch {
78
+ // неожиданный сбой не должен обрывать цепочку продления
79
+ result = 'retry';
80
+ }
81
+
82
+ if (result === 'ok') {
83
+ this.arm();
84
+ } else if (result === 'retry') {
85
+ this._schedule(this._retryMs);
86
+ } else {
87
+ this._onLost();
88
+ }
89
+ }
90
+ }