vimp-engine 0.24.2 → 0.26.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/package.json +2 -1
- package/src/client/components/controller/Games.js +99 -0
- package/src/client/components/model/Games.js +215 -0
- package/src/client/components/model/LobbyAuth.js +8 -0
- package/src/client/components/view/Games.js +365 -0
- package/src/client/lib/catalogState.js +44 -0
- package/src/client/lib/gameActivator.js +13 -5
- package/src/client/main.js +183 -21
- package/src/client/style.css +101 -0
- package/src/client/views/gameShell.js +31 -0
- package/src/client/views/includes/games.pug +36 -0
- package/src/client/views/includes/lobby.pug +7 -1
- package/src/client/views/index.pug +1 -0
- package/src/config/env.js +4 -4
- package/src/config/lobby.js +88 -3
- package/src/config/master.js +33 -13
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import Publisher from '../../../lib/Publisher.js';
|
|
2
|
+
import { renderFormErrors } from '../../lib/formBuilder.js';
|
|
3
|
+
|
|
4
|
+
// Singleton GamesView
|
|
5
|
+
|
|
6
|
+
let gamesView;
|
|
7
|
+
|
|
8
|
+
// коды отказов мастера/auth — человеческая формулировка живёт здесь, как в
|
|
9
|
+
// LobbyAuthView: модель кодов не переводит, view не ходит в сеть.
|
|
10
|
+
// Язык интерфейса — английский, как и в остальном лобби
|
|
11
|
+
const ERROR_MESSAGES = {
|
|
12
|
+
unauthorized: 'Please sign in again',
|
|
13
|
+
forbidden: 'Not enough rights',
|
|
14
|
+
network: 'Network unavailable, try again',
|
|
15
|
+
requestFailed: 'Request failed',
|
|
16
|
+
// лимитер мастера (5 заявок в минуту на пользователя) считает и заявки,
|
|
17
|
+
// отклонённые по формату, — без своей строки код уезжал бы в интерфейс сырым
|
|
18
|
+
tooManyRequests: 'Too many requests, try again in a minute',
|
|
19
|
+
gameExists: 'A game with this id already exists',
|
|
20
|
+
tooManyGames: 'Too many submissions from one author',
|
|
21
|
+
unknownGame: 'Game is not in the registry',
|
|
22
|
+
invalidGameId: 'Invalid game id',
|
|
23
|
+
invalidPackageName: 'Invalid npm package name',
|
|
24
|
+
invalidVersion: 'Invalid version',
|
|
25
|
+
invalidTitle: 'Invalid title',
|
|
26
|
+
invalidRepoUrl: 'Invalid repository URL',
|
|
27
|
+
invalidMaxGameScore: 'Invalid score cap',
|
|
28
|
+
authServiceUnavailable: 'Registry service unavailable',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// предупреждения: решение принято, отказа не было, но состояние платформы
|
|
32
|
+
// стоит назвать вслух
|
|
33
|
+
const WARNING_MESSAGES = {
|
|
34
|
+
catalogEmpty:
|
|
35
|
+
'No published games left — the lobby cannot create rooms until one is published',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const STATUS_TITLES = {
|
|
39
|
+
pending: 'in review',
|
|
40
|
+
approved: 'published',
|
|
41
|
+
rejected: 'rejected',
|
|
42
|
+
disabled: 'disabled',
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// Представление реестра игр: списки заявок и очереди модерации, фильтры,
|
|
46
|
+
// строки ошибок. В сеть не ходит и состояния не держит — рисует то, что
|
|
47
|
+
// пришло событием модели, и публикует намерения пользователя
|
|
48
|
+
export default class GamesView {
|
|
49
|
+
/**
|
|
50
|
+
* @param {Object} model - GamesModel (источник событий).
|
|
51
|
+
* @param {Object} config - Блок `games` конфига лобби.
|
|
52
|
+
*/
|
|
53
|
+
constructor(model, config) {
|
|
54
|
+
if (gamesView) {
|
|
55
|
+
return gamesView;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
gamesView = this;
|
|
59
|
+
|
|
60
|
+
const { elems } = config;
|
|
61
|
+
|
|
62
|
+
this._config = config;
|
|
63
|
+
this._elems = elems;
|
|
64
|
+
|
|
65
|
+
this._panel = document.getElementById(elems.panelId);
|
|
66
|
+
this._lobby = document.getElementById(elems.lobbyId);
|
|
67
|
+
this._openMine = document.getElementById(elems.openMineBtnId);
|
|
68
|
+
this._openModeration = document.getElementById(elems.openModerationBtnId);
|
|
69
|
+
this._close = document.getElementById(elems.closeBtnId);
|
|
70
|
+
|
|
71
|
+
this._mineList = document.getElementById(elems.mineListId);
|
|
72
|
+
this._submitForm = document.getElementById(elems.submitFormId);
|
|
73
|
+
this._submitError = document.getElementById(elems.submitErrorId);
|
|
74
|
+
|
|
75
|
+
this._moderation = document.getElementById(elems.moderationId);
|
|
76
|
+
this._adminList = document.getElementById(elems.adminListId);
|
|
77
|
+
this._adminError = document.getElementById(elems.adminErrorId);
|
|
78
|
+
this._filters = document.getElementById(elems.filtersId);
|
|
79
|
+
|
|
80
|
+
this._fields = new Map(
|
|
81
|
+
Object.entries(elems.fieldIds).map(([name, id]) => {
|
|
82
|
+
const field = document.getElementById(id);
|
|
83
|
+
|
|
84
|
+
// карта строится по конфигу, а используется из обработчиков событий:
|
|
85
|
+
// разъехавшийся с games.pug id дал бы здесь null, а упал бы позже —
|
|
86
|
+
// безымянным TypeError внутри clearForm или _readForm. Остальная
|
|
87
|
+
// разметка панели проверяется тем же способом, только неявно: первое
|
|
88
|
+
// же обращение к отсутствующему элементу бросает в конструкторе
|
|
89
|
+
if (!field) {
|
|
90
|
+
throw new Error(`GamesView: no element "#${id}" for field "${name}"`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return [name, field];
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
this.publisher = new Publisher();
|
|
98
|
+
|
|
99
|
+
this._openMine.onclick = () => this.publisher.emit('open-mine');
|
|
100
|
+
this._openModeration.onclick = () => this.publisher.emit('open-moderation');
|
|
101
|
+
this._close.onclick = () => this.hide();
|
|
102
|
+
|
|
103
|
+
this._submitForm.onsubmit = e => {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
this.publisher.emit('submit', this._readForm());
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
this._renderFilters();
|
|
109
|
+
|
|
110
|
+
const mp = model.publisher;
|
|
111
|
+
|
|
112
|
+
mp.on('submitted', 'clearForm', this);
|
|
113
|
+
mp.on('mine-changed', 'renderMine', this);
|
|
114
|
+
mp.on('admin-changed', 'renderAdmin', this);
|
|
115
|
+
mp.on('error', 'renderError', this);
|
|
116
|
+
mp.on('warning', 'renderWarning', this);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// кнопку модерации показывает не сама панель, а роль вызывающего
|
|
120
|
+
// (main.js по LobbyAuthModel.getRole())
|
|
121
|
+
setAdmin(isAdmin) {
|
|
122
|
+
this._openModeration.style.display = isAdmin ? '' : 'none';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
show(moderation = false) {
|
|
126
|
+
this._panel.style.display = 'flex';
|
|
127
|
+
this._lobby.style.display = 'none';
|
|
128
|
+
this._moderation.style.display = moderation ? 'block' : 'none';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
hide() {
|
|
132
|
+
// закрытой панели закрывать нечего: возврат черновиков после
|
|
133
|
+
// перезагрузки идёт тем же событием 'staged', что и «Test», и не должен
|
|
134
|
+
// трогать разметку лобби
|
|
135
|
+
if (this._panel.style.display === 'none') {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this._panel.style.display = 'none';
|
|
140
|
+
this._lobby.style.display = 'flex';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// заявка ушла — поля пустые: следующая отправка начинается с чистой формы
|
|
144
|
+
clearForm() {
|
|
145
|
+
this._fields.forEach(field => {
|
|
146
|
+
field.value = '';
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
renderMine(games) {
|
|
151
|
+
this._submitError.textContent = '';
|
|
152
|
+
this._mineList.textContent = '';
|
|
153
|
+
|
|
154
|
+
(games || []).forEach(game => {
|
|
155
|
+
const item = document.createElement('li');
|
|
156
|
+
|
|
157
|
+
item.className = 'games-item';
|
|
158
|
+
item.appendChild(
|
|
159
|
+
this._line(
|
|
160
|
+
`${game.title ? `${game.title} · ` : ''}${game.id} — ` +
|
|
161
|
+
`${game.packageName} @ ${game.version ?? '—'}`,
|
|
162
|
+
'games-item-title',
|
|
163
|
+
),
|
|
164
|
+
);
|
|
165
|
+
item.appendChild(this._line(this._statusLine(game)));
|
|
166
|
+
this._appendRepo(item, game.repoUrl);
|
|
167
|
+
|
|
168
|
+
if (game.moderatorNote) {
|
|
169
|
+
item.appendChild(this._line(`Note: ${game.moderatorNote}`));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// заявка на новую версию своей игры: поле рядом со строкой, а не
|
|
173
|
+
// отдельной формой — версия относится к конкретной заявке
|
|
174
|
+
const version = document.createElement('input');
|
|
175
|
+
const send = document.createElement('input');
|
|
176
|
+
|
|
177
|
+
version.type = 'text';
|
|
178
|
+
version.className = 'field-text games-version-input';
|
|
179
|
+
version.placeholder = 'New version';
|
|
180
|
+
send.type = 'button';
|
|
181
|
+
send.value = 'Update version';
|
|
182
|
+
send.onclick = () =>
|
|
183
|
+
this.publisher.emit('update-version', { id: game.id, version: version.value.trim() });
|
|
184
|
+
|
|
185
|
+
item.appendChild(version);
|
|
186
|
+
item.appendChild(send);
|
|
187
|
+
this._mineList.appendChild(item);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
renderAdmin({ games, filter, versions }) {
|
|
192
|
+
this._adminError.textContent = '';
|
|
193
|
+
this._adminList.textContent = '';
|
|
194
|
+
this._markFilter(filter);
|
|
195
|
+
|
|
196
|
+
(games || []).forEach(game => {
|
|
197
|
+
this._adminList.appendChild(this._adminItem(game, versions));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
renderWarning({ scope, code }) {
|
|
202
|
+
const container = scope === 'admin' ? this._adminError : this._submitError;
|
|
203
|
+
|
|
204
|
+
container.textContent = WARNING_MESSAGES[code] ?? code;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
renderError({ scope, errors }) {
|
|
208
|
+
const container = scope === 'admin' ? this._adminError : this._submitError;
|
|
209
|
+
|
|
210
|
+
renderFormErrors(
|
|
211
|
+
container,
|
|
212
|
+
errors.map(({ name, error }) => ({
|
|
213
|
+
name,
|
|
214
|
+
label: name,
|
|
215
|
+
error: ERROR_MESSAGES[error] ?? error,
|
|
216
|
+
})),
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
_adminItem(game, versions) {
|
|
221
|
+
const item = document.createElement('li');
|
|
222
|
+
const published = versions?.get(game.id) ?? [];
|
|
223
|
+
const latest = published[published.length - 1];
|
|
224
|
+
|
|
225
|
+
item.className = 'games-item';
|
|
226
|
+
item.appendChild(
|
|
227
|
+
this._line(
|
|
228
|
+
`${game.title ? `${game.title} · ` : ''}${game.id} — ${game.packageName}`,
|
|
229
|
+
'games-item-title',
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
// у игр, засеянных миграцией, автора нет вовсе: без запасного прочерка
|
|
233
|
+
// в строке печаталось бы литеральное "null"
|
|
234
|
+
item.appendChild(this._line(`Author: ${game.authorNick ?? game.authorUserId ?? '—'}`));
|
|
235
|
+
this._appendRepo(item, game.repoUrl);
|
|
236
|
+
item.appendChild(
|
|
237
|
+
this._line(
|
|
238
|
+
`Served: ${game.version ?? '—'}; requested: ${game.pendingVersion ?? '—'}` +
|
|
239
|
+
(latest ? `; in npm: ${latest}` : ''),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
242
|
+
item.appendChild(this._line(this._statusLine(game)));
|
|
243
|
+
|
|
244
|
+
if (game.local) {
|
|
245
|
+
item.appendChild(
|
|
246
|
+
this._line(
|
|
247
|
+
`On this master: ${game.local.downloaded ? 'downloaded' : 'not downloaded'}` +
|
|
248
|
+
(game.local.stagedVersion ? `; staged ${game.local.stagedVersion}` : '') +
|
|
249
|
+
(game.local.lastError ? `; error: ${game.local.lastError}` : ''),
|
|
250
|
+
),
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const note = document.createElement('input');
|
|
255
|
+
|
|
256
|
+
note.type = 'text';
|
|
257
|
+
note.className = 'field-text games-note-input';
|
|
258
|
+
note.placeholder = 'Rejection reason';
|
|
259
|
+
|
|
260
|
+
item.appendChild(note);
|
|
261
|
+
item.appendChild(
|
|
262
|
+
this._button('Test', () =>
|
|
263
|
+
this.publisher.emit('stage', {
|
|
264
|
+
id: game.id,
|
|
265
|
+
version: game.pendingVersion ?? game.version,
|
|
266
|
+
}),
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
item.appendChild(
|
|
270
|
+
this._button('Approve', () => this.publisher.emit('approve', { id: game.id })),
|
|
271
|
+
);
|
|
272
|
+
item.appendChild(
|
|
273
|
+
this._button('Reject', () =>
|
|
274
|
+
this.publisher.emit('reject', { id: game.id, note: note.value.trim() }),
|
|
275
|
+
),
|
|
276
|
+
);
|
|
277
|
+
item.appendChild(
|
|
278
|
+
this._button('Disable', () => this.publisher.emit('disable', { id: game.id })),
|
|
279
|
+
);
|
|
280
|
+
item.appendChild(
|
|
281
|
+
this._button('npm versions', () =>
|
|
282
|
+
this.publisher.emit('load-versions', { id: game.id }),
|
|
283
|
+
),
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
return item;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Ссылка на репозиторий игры. Протокол проверяется и здесь, хотя auth уже
|
|
290
|
+
// принимает только http(s): href — единственное место представления, где
|
|
291
|
+
// содержимое поля становится исполняемым (javascript:), и полагаться на
|
|
292
|
+
// одну проверку на другой стороне сети тут не стоит
|
|
293
|
+
_appendRepo(item, url) {
|
|
294
|
+
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const line = document.createElement('div');
|
|
299
|
+
const link = document.createElement('a');
|
|
300
|
+
|
|
301
|
+
link.href = url;
|
|
302
|
+
link.textContent = url;
|
|
303
|
+
link.target = '_blank';
|
|
304
|
+
link.rel = 'noopener noreferrer';
|
|
305
|
+
line.appendChild(link);
|
|
306
|
+
item.appendChild(line);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
_statusLine(game) {
|
|
310
|
+
const status = STATUS_TITLES[game.status] ?? game.status;
|
|
311
|
+
const date = game.createdAt ? new Date(game.createdAt).toLocaleDateString() : '';
|
|
312
|
+
|
|
313
|
+
return date ? `Status: ${status} (submitted ${date})` : `Status: ${status}`;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
_renderFilters() {
|
|
317
|
+
this._filterButtons = new Map();
|
|
318
|
+
|
|
319
|
+
this._config.statuses.forEach(({ id, title }) => {
|
|
320
|
+
const btn = this._button(title, () => this.publisher.emit('filter', id));
|
|
321
|
+
|
|
322
|
+
btn.className = 'games-filter-btn';
|
|
323
|
+
this._filterButtons.set(id, btn);
|
|
324
|
+
this._filters.appendChild(btn);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
_markFilter(filter) {
|
|
329
|
+
this._filterButtons.forEach((btn, id) => {
|
|
330
|
+
btn.classList.toggle('active', id === filter);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
_button(value, onclick) {
|
|
335
|
+
const btn = document.createElement('input');
|
|
336
|
+
|
|
337
|
+
btn.type = 'button';
|
|
338
|
+
btn.value = value;
|
|
339
|
+
btn.onclick = onclick;
|
|
340
|
+
|
|
341
|
+
return btn;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_line(text, className) {
|
|
345
|
+
const line = document.createElement('div');
|
|
346
|
+
|
|
347
|
+
line.textContent = text;
|
|
348
|
+
|
|
349
|
+
if (className) {
|
|
350
|
+
line.className = className;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return line;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
_readForm() {
|
|
357
|
+
const form = {};
|
|
358
|
+
|
|
359
|
+
this._fields.forEach((field, name) => {
|
|
360
|
+
form[name] = field.value.trim();
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
return form;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Каталог игр платформы может быть ПУСТ, и это законное состояние лобби, а не
|
|
2
|
+
// отказ загрузки: игры живут в реестре auth-сервиса, модератор вправе снять с
|
|
3
|
+
// раздачи последнюю, а на первом развёртывании не одобрена ещё ни одна.
|
|
4
|
+
//
|
|
5
|
+
// Лобби в этом состоянии обязано жить целиком: вход, бейдж пользователя, «My
|
|
6
|
+
// games» и «Moderation» от игры не зависят — и только через них каталог
|
|
7
|
+
// возвращается к жизни. Раньше пустой каталог бросал из бутстрапа и стирал
|
|
8
|
+
// разметку страницы: модератор, отключивший последнюю игру, запирал сам себя
|
|
9
|
+
// и вернуть её мог только запросом мимо интерфейса.
|
|
10
|
+
//
|
|
11
|
+
// Вынесено из main.js (бутстрап, в happy-dom не поднимается) отдельным
|
|
12
|
+
// модулем, чтобы обе ветки проверялись юнит-тестом.
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Приводит лобби в соответствие с активной игрой.
|
|
16
|
+
* @param {Object} [manifest] - Манифест активной игры; `undefined` — каталог
|
|
17
|
+
* платформы пуст.
|
|
18
|
+
* @param {Object} deps - Проводка лобби.
|
|
19
|
+
* @param {HTMLElement} [deps.hostBtn] - Кнопка «Create server».
|
|
20
|
+
* @param {string} deps.emptyText - Строка отказа для пустого каталога.
|
|
21
|
+
* @param {Function} deps.bindGame - Форма комнаты и Leaderboard активной игры.
|
|
22
|
+
* @param {Function} deps.showError - Показать строку отказа лобби.
|
|
23
|
+
* @param {Function} deps.clearError - Снять строку отказа лобби.
|
|
24
|
+
* @returns {void}
|
|
25
|
+
*/
|
|
26
|
+
export function applyCatalogState(
|
|
27
|
+
manifest,
|
|
28
|
+
{ hostBtn, emptyText, bindGame, showError, clearError },
|
|
29
|
+
) {
|
|
30
|
+
if (manifest) {
|
|
31
|
+
bindGame(manifest);
|
|
32
|
+
clearError();
|
|
33
|
+
} else {
|
|
34
|
+
// форму комнаты и Leaderboard не трогаем вовсе: без игры у первой нет
|
|
35
|
+
// схемы полей, а второй — игры, за рейтингом которой идти
|
|
36
|
+
showError(emptyText);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (hostBtn) {
|
|
40
|
+
hostBtn.disabled = !manifest;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default applyCatalogState;
|
|
@@ -8,8 +8,14 @@
|
|
|
8
8
|
// Вынесено из main.js (бутстрап, тестами не покрывается) отдельным модулем,
|
|
9
9
|
// чтобы кеш и обработка отказа проверялись юнит-тестом.
|
|
10
10
|
export function createGameActivator({ gamesById, loadClientPlugin }) {
|
|
11
|
-
// gameId -> промис загрузки: кешируется именно
|
|
12
|
-
// клика подряд запустили бы импорт
|
|
11
|
+
// `${gameId}@${manifest.version}` -> промис загрузки: кешируется именно
|
|
12
|
+
// промис, иначе два быстрых клика подряд запустили бы импорт дважды.
|
|
13
|
+
//
|
|
14
|
+
// Версия в ключе обязательна (master-game-registry, этап 3): в каталоге
|
|
15
|
+
// мастера две версии одной игры живут одновременно (админ стейджит новую,
|
|
16
|
+
// игроки играют в одобренную), и ключ по одному gameId вернул бы уже
|
|
17
|
+
// выполненный import() НЕ ТОГО кода. manifest.version — хеш бандла, то
|
|
18
|
+
// есть идентификатор самого кода
|
|
13
19
|
const plugins = new Map();
|
|
14
20
|
|
|
15
21
|
return async function activateGame(gameId) {
|
|
@@ -19,18 +25,20 @@ export function createGameActivator({ gamesById, loadClientPlugin }) {
|
|
|
19
25
|
throw new Error(`unknown game "${gameId}"`);
|
|
20
26
|
}
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
const key = `${gameId}@${manifest.version}`;
|
|
29
|
+
|
|
30
|
+
let pending = plugins.get(key);
|
|
23
31
|
|
|
24
32
|
if (!pending) {
|
|
25
33
|
pending = loadClientPlugin(manifest).catch(e => {
|
|
26
34
|
// отказ не кешируем: сеть могла моргнуть, повторный клик обязан
|
|
27
35
|
// попробовать снова, а не переигрывать ту же ошибку вечно
|
|
28
|
-
plugins.delete(
|
|
36
|
+
plugins.delete(key);
|
|
29
37
|
|
|
30
38
|
throw e;
|
|
31
39
|
});
|
|
32
40
|
|
|
33
|
-
plugins.set(
|
|
41
|
+
plugins.set(key, pending);
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
return { manifest, plugin: await pending };
|