vimp-engine 0.34.7 → 0.35.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/boot.js +1 -0
- package/src/client/components/controller/ClientReports.js +54 -0
- package/src/client/components/model/ClientReports.js +170 -0
- package/src/client/components/view/ClientReports.js +317 -0
- package/src/client/lib/diagnostics.js +430 -0
- package/src/client/main.js +56 -0
- package/src/client/network/HostController.js +50 -2
- package/src/client/style.css +106 -1
- package/src/client/views/includes/lobby.pug +2 -0
- package/src/client/views/includes/reports.pug +18 -0
- package/src/client/views/index.pug +1 -0
- package/src/config/clientServices.js +2 -0
- package/src/config/env.js +6 -0
- package/src/config/lobby.js +39 -0
- package/src/config/master.js +20 -0
- package/src/host/HostGame.js +12 -0
- package/src/host/host.worker.js +14 -0
- package/src/host/meta/core/RoundManager.js +109 -43
- package/src/lib/capabilities.js +3 -0
- package/src/lib/clientIp.js +50 -0
- package/src/standalone/index.js +5 -0
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// Журнал клиентских ошибок (plan/client-reports, этап 3): неперехваченные
|
|
2
|
+
// ошибки и отклонения страницы, ошибки Worker'а хоста, нарушения CSP и явные
|
|
3
|
+
// warn/capture плагинов уходят на свой же бокс (POST /client-reports), а тот
|
|
4
|
+
// пересылает их в центральный журнал.
|
|
5
|
+
//
|
|
6
|
+
// Репортёр не должен стать новым источником падений и шума: каждая
|
|
7
|
+
// публичная функция глушит свои исключения, повтор известной ошибки — только
|
|
8
|
+
// счётчик, разных ошибок за сессию не больше maxKeysPerSession, отправка —
|
|
9
|
+
// пачками с дебаунсом и дельтами счётчиков. console.* не перехватывается
|
|
10
|
+
// осознанно: там шум и случайные данные в аргументах.
|
|
11
|
+
|
|
12
|
+
// обрезки — те же числа, что у приёма на боксе (stage_2.md, контракт)
|
|
13
|
+
const MAX_MESSAGE = 500;
|
|
14
|
+
const MAX_STACK = 4000;
|
|
15
|
+
const MAX_CODE = 64;
|
|
16
|
+
const MAX_DETAILS_BYTES = 2048;
|
|
17
|
+
|
|
18
|
+
// пачка: не больше 10 записей (лимит бокса) и заметно меньше его 16 КБ тела
|
|
19
|
+
const MAX_BATCH_ITEMS = 10;
|
|
20
|
+
const MAX_BATCH_BYTES = 15000;
|
|
21
|
+
const SHRUNK_STACK = 1000;
|
|
22
|
+
|
|
23
|
+
const CODE_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
|
|
24
|
+
|
|
25
|
+
// кадр стека с позицией «:строка:колонка» — он и различает одинаковые
|
|
26
|
+
// сообщения из разных мест
|
|
27
|
+
const FRAME_RE = /:\d+:\d+/;
|
|
28
|
+
|
|
29
|
+
// расширения браузера игрока внедряют скрипты и стили — их нарушения CSP
|
|
30
|
+
// утопили бы журнал в чужом шуме
|
|
31
|
+
const EXTENSION_PREFIXES = [
|
|
32
|
+
'chrome-extension:',
|
|
33
|
+
'moz-extension:',
|
|
34
|
+
'safari-extension:',
|
|
35
|
+
'safari-web-extension:',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
// служебные значения blockedURI — не URL, берутся как есть
|
|
39
|
+
const CSP_KEYWORDS = new Set(['', 'inline', 'eval', 'wasm-eval', 'data', 'blob']);
|
|
40
|
+
|
|
41
|
+
const encoder = new TextEncoder();
|
|
42
|
+
|
|
43
|
+
function byteLength(str) {
|
|
44
|
+
return encoder.encode(str).length;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeSessionId() {
|
|
48
|
+
try {
|
|
49
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
50
|
+
return crypto.randomUUID();
|
|
51
|
+
}
|
|
52
|
+
} catch {
|
|
53
|
+
// нет безопасного контекста — ниже фолбэк
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function safeString(value) {
|
|
62
|
+
try {
|
|
63
|
+
return String(value);
|
|
64
|
+
} catch {
|
|
65
|
+
return '[unprintable]';
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function topFrame(stack) {
|
|
70
|
+
if (!stack) {
|
|
71
|
+
return '';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return stack.split('\n').find(line => FRAME_RE.test(line))?.trim() ?? '';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// снимок деталей: простой объект ≤ 2048 байт JSON, иначе пометка усечения
|
|
78
|
+
function normalizeDetails(details) {
|
|
79
|
+
if (details === null || details === undefined) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
const json = JSON.stringify(details);
|
|
85
|
+
|
|
86
|
+
if (
|
|
87
|
+
typeof details !== 'object' ||
|
|
88
|
+
json === undefined ||
|
|
89
|
+
byteLength(json) > MAX_DETAILS_BYTES
|
|
90
|
+
) {
|
|
91
|
+
return { truncated: true };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return JSON.parse(json);
|
|
95
|
+
} catch {
|
|
96
|
+
return { truncated: true };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// URL без query и hash: в них бывают токены и прочие личные данные
|
|
101
|
+
function stripUrl(value) {
|
|
102
|
+
const str = typeof value === 'string' ? value : '';
|
|
103
|
+
|
|
104
|
+
if (CSP_KEYWORDS.has(str)) {
|
|
105
|
+
return str;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
const url = new URL(str);
|
|
110
|
+
|
|
111
|
+
return url.origin + url.pathname;
|
|
112
|
+
} catch {
|
|
113
|
+
return str.split(/[?#]/)[0];
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function isExtensionUrl(value) {
|
|
118
|
+
return (
|
|
119
|
+
typeof value === 'string' &&
|
|
120
|
+
EXTENSION_PREFIXES.some(prefix => value.startsWith(prefix))
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// транспорт по умолчанию: beacon переживает закрытие вкладки; нет его или
|
|
125
|
+
// очередь переполнена — fetch с keepalive. Ответ не читается
|
|
126
|
+
function defaultSend(url, json) {
|
|
127
|
+
if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
|
|
128
|
+
const blob = new Blob([json], { type: 'application/json' });
|
|
129
|
+
|
|
130
|
+
if (navigator.sendBeacon(url, blob)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
fetch(url, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
body: json,
|
|
138
|
+
headers: { 'content-type': 'application/json' },
|
|
139
|
+
keepalive: true,
|
|
140
|
+
credentials: 'same-origin',
|
|
141
|
+
}).catch(() => {});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function noop() {}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Создаёт репортёр клиентских ошибок.
|
|
148
|
+
* @param {Object} opts
|
|
149
|
+
* @param {string|null} opts.url - адрес приёма; null — репортёр выключен
|
|
150
|
+
* @param {Object} [opts.context] - { mode, role, gameId, gameVersion, page, userAgent }
|
|
151
|
+
* @param {Function} [opts.send] - (url, json) => void; по умолчанию beacon/fetch
|
|
152
|
+
* @param {Function} [opts.now]
|
|
153
|
+
* @param {number} [opts.maxKeysPerSession=50]
|
|
154
|
+
* @param {number} [opts.flushDelayMs=2000]
|
|
155
|
+
* @returns {{ capture, warn, flush, setContext, install }}
|
|
156
|
+
*/
|
|
157
|
+
export function createDiagnostics({
|
|
158
|
+
url,
|
|
159
|
+
context = {},
|
|
160
|
+
send = defaultSend,
|
|
161
|
+
now = Date.now,
|
|
162
|
+
maxKeysPerSession = 50,
|
|
163
|
+
flushDelayMs = 2000,
|
|
164
|
+
} = {}) {
|
|
165
|
+
if (!url) {
|
|
166
|
+
return {
|
|
167
|
+
capture: noop,
|
|
168
|
+
warn: noop,
|
|
169
|
+
flush: noop,
|
|
170
|
+
setContext: noop,
|
|
171
|
+
install: () => noop,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const sessionId = makeSessionId();
|
|
176
|
+
const ctx = { ...context };
|
|
177
|
+
|
|
178
|
+
// ключ дедупа → запись со счётчиками: count — всего за сессию, sentCount —
|
|
179
|
+
// сколько уже отправлено, deltaFirstAt — первое время в неотправленной дельте
|
|
180
|
+
const records = new Map();
|
|
181
|
+
|
|
182
|
+
let capWarned = false;
|
|
183
|
+
let timer = null;
|
|
184
|
+
|
|
185
|
+
// ошибка внутри самого репортёра не должна порождать новый отчёт
|
|
186
|
+
let busy = false;
|
|
187
|
+
|
|
188
|
+
function record(item) {
|
|
189
|
+
const key = `${item.source}|${item.kind}|${item.code ?? item.message}|${topFrame(item.stack)}`;
|
|
190
|
+
const at = now();
|
|
191
|
+
const known = records.get(key);
|
|
192
|
+
|
|
193
|
+
if (known) {
|
|
194
|
+
known.count += 1;
|
|
195
|
+
known.lastAt = at;
|
|
196
|
+
|
|
197
|
+
if (known.count - known.sentCount === 1) {
|
|
198
|
+
known.deltaFirstAt = at;
|
|
199
|
+
}
|
|
200
|
+
} else {
|
|
201
|
+
if (records.size >= maxKeysPerSession) {
|
|
202
|
+
if (!capWarned) {
|
|
203
|
+
capWarned = true;
|
|
204
|
+
console.warn('[vimp] diagnostics: session cap reached');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
records.set(key, {
|
|
211
|
+
item,
|
|
212
|
+
count: 1,
|
|
213
|
+
sentCount: 0,
|
|
214
|
+
deltaFirstAt: at,
|
|
215
|
+
lastAt: at,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
schedule();
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function schedule() {
|
|
223
|
+
if (timer === null) {
|
|
224
|
+
timer = setTimeout(flush, flushDelayMs);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function guarded(fn) {
|
|
229
|
+
if (busy) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
busy = true;
|
|
234
|
+
|
|
235
|
+
try {
|
|
236
|
+
fn();
|
|
237
|
+
} catch {
|
|
238
|
+
// репортёр молчит о своих сбоях — иначе он сам стал бы шумом
|
|
239
|
+
} finally {
|
|
240
|
+
busy = false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function capture(error, { source = 'client', kind = 'error' } = {}) {
|
|
245
|
+
guarded(() => {
|
|
246
|
+
const raw =
|
|
247
|
+
error !== null && error !== undefined && error.message !== undefined
|
|
248
|
+
? error.message
|
|
249
|
+
: error;
|
|
250
|
+
const stack =
|
|
251
|
+
error !== null && error !== undefined && typeof error.stack === 'string'
|
|
252
|
+
? error.stack.slice(0, MAX_STACK)
|
|
253
|
+
: null;
|
|
254
|
+
|
|
255
|
+
record({
|
|
256
|
+
kind,
|
|
257
|
+
source,
|
|
258
|
+
code: null,
|
|
259
|
+
message: safeString(raw).slice(0, MAX_MESSAGE),
|
|
260
|
+
stack,
|
|
261
|
+
details: null,
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function warn(code, details = null, { source = 'plugin', message } = {}) {
|
|
267
|
+
guarded(() => {
|
|
268
|
+
const safeCode =
|
|
269
|
+
typeof code === 'string' && CODE_RE.test(code)
|
|
270
|
+
? code.slice(0, MAX_CODE)
|
|
271
|
+
: 'invalid-code';
|
|
272
|
+
const stack = new Error().stack;
|
|
273
|
+
|
|
274
|
+
record({
|
|
275
|
+
kind: 'warn',
|
|
276
|
+
source,
|
|
277
|
+
code: safeCode,
|
|
278
|
+
message: safeString(message ?? safeCode).slice(0, MAX_MESSAGE),
|
|
279
|
+
stack: typeof stack === 'string' ? stack.slice(0, MAX_STACK) : null,
|
|
280
|
+
details: normalizeDetails(details),
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// нарушение CSP (только в проде: CSP ставит мастер) — одно правило,
|
|
286
|
+
// сработавшее тысячу раз, одна запись со счётчиком
|
|
287
|
+
function reportCsp(event) {
|
|
288
|
+
guarded(() => {
|
|
289
|
+
if (isExtensionUrl(event.blockedURI) || isExtensionUrl(event.sourceFile)) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const blocked = stripUrl(event.blockedURI);
|
|
294
|
+
|
|
295
|
+
record({
|
|
296
|
+
kind: 'csp',
|
|
297
|
+
source: 'client',
|
|
298
|
+
code: null,
|
|
299
|
+
message: `CSP ${event.effectiveDirective} blocked ${blocked}`.slice(
|
|
300
|
+
0,
|
|
301
|
+
MAX_MESSAGE,
|
|
302
|
+
),
|
|
303
|
+
stack: null,
|
|
304
|
+
details: normalizeDetails({
|
|
305
|
+
directive: event.effectiveDirective,
|
|
306
|
+
disposition: event.disposition,
|
|
307
|
+
sourceFile: stripUrl(event.sourceFile),
|
|
308
|
+
line: event.lineNumber,
|
|
309
|
+
column: event.columnNumber,
|
|
310
|
+
sample: (event.sample || '').slice(0, 40),
|
|
311
|
+
}),
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function body(items) {
|
|
317
|
+
return JSON.stringify({ v: 1, sessionId, context: ctx, items });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// пачка больше предела: сперва без деталей, затем с короткими стеками;
|
|
321
|
+
// не помогло — по одной записи
|
|
322
|
+
function sendBatch(items) {
|
|
323
|
+
let json = body(items);
|
|
324
|
+
|
|
325
|
+
if (byteLength(json) > MAX_BATCH_BYTES) {
|
|
326
|
+
items = items.map(item => ({ ...item, details: null }));
|
|
327
|
+
json = body(items);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (byteLength(json) > MAX_BATCH_BYTES) {
|
|
331
|
+
items = items.map(item => ({
|
|
332
|
+
...item,
|
|
333
|
+
stack: item.stack ? item.stack.slice(0, SHRUNK_STACK) : item.stack,
|
|
334
|
+
}));
|
|
335
|
+
json = body(items);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (byteLength(json) > MAX_BATCH_BYTES && items.length > 1) {
|
|
339
|
+
for (const item of items) {
|
|
340
|
+
send(url, body([item]));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
send(url, json);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// уходят только записи, у которых с прошлой отправки вырос счётчик, —
|
|
350
|
+
// приростом, а не итогом (бокс складывает)
|
|
351
|
+
function flush() {
|
|
352
|
+
if (timer !== null) {
|
|
353
|
+
clearTimeout(timer);
|
|
354
|
+
timer = null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
guarded(() => {
|
|
358
|
+
const items = [];
|
|
359
|
+
|
|
360
|
+
for (const rec of records.values()) {
|
|
361
|
+
const delta = rec.count - rec.sentCount;
|
|
362
|
+
|
|
363
|
+
if (delta > 0) {
|
|
364
|
+
items.push({
|
|
365
|
+
...rec.item,
|
|
366
|
+
count: delta,
|
|
367
|
+
firstAt: rec.deltaFirstAt,
|
|
368
|
+
lastAt: rec.lastAt,
|
|
369
|
+
});
|
|
370
|
+
rec.sentCount = rec.count;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
for (let i = 0; i < items.length; i += MAX_BATCH_ITEMS) {
|
|
375
|
+
sendBatch(items.slice(i, i + MAX_BATCH_ITEMS));
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function setContext(patch) {
|
|
381
|
+
try {
|
|
382
|
+
Object.assign(ctx, patch);
|
|
383
|
+
} catch {
|
|
384
|
+
// контекст — вспомогательный, его сбой отчёты не останавливает
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Вешает глобальные перехватчики.
|
|
390
|
+
* @param {Window} [target]
|
|
391
|
+
* @returns {Function} снятие перехватчиков
|
|
392
|
+
*/
|
|
393
|
+
function install(target = window) {
|
|
394
|
+
// ошибки загрузки <img>/<script> приходят обычным Event на элементе —
|
|
395
|
+
// это сбои ресурсов, а не исключения
|
|
396
|
+
const onError = event => {
|
|
397
|
+
if (typeof ErrorEvent !== 'undefined' && event instanceof ErrorEvent) {
|
|
398
|
+
capture(event.error ?? event.message, { kind: 'error' });
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
const onRejection = event => {
|
|
402
|
+
capture(event.reason, { kind: 'rejection' });
|
|
403
|
+
};
|
|
404
|
+
const onPageHide = () => flush();
|
|
405
|
+
const doc = target.document;
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
target.addEventListener('error', onError);
|
|
409
|
+
target.addEventListener('unhandledrejection', onRejection);
|
|
410
|
+
target.addEventListener('pagehide', onPageHide);
|
|
411
|
+
// событие всплывает к документу
|
|
412
|
+
doc?.addEventListener('securitypolicyviolation', reportCsp);
|
|
413
|
+
} catch {
|
|
414
|
+
// нет цели — просто не ловим
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return () => {
|
|
418
|
+
try {
|
|
419
|
+
target.removeEventListener('error', onError);
|
|
420
|
+
target.removeEventListener('unhandledrejection', onRejection);
|
|
421
|
+
target.removeEventListener('pagehide', onPageHide);
|
|
422
|
+
doc?.removeEventListener('securitypolicyviolation', reportCsp);
|
|
423
|
+
} catch {
|
|
424
|
+
// снятие не должно ронять вызывающего
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return { capture, warn, flush, setContext, install };
|
|
430
|
+
}
|
package/src/client/main.js
CHANGED
|
@@ -45,6 +45,7 @@ import {
|
|
|
45
45
|
import { createContextTracker } from './lib/contextTracker.js';
|
|
46
46
|
import { createLocalPlayer } from './lib/localPlayer.js';
|
|
47
47
|
import { createAccolades } from './lib/accolades.js';
|
|
48
|
+
import { createDiagnostics } from './lib/diagnostics.js';
|
|
48
49
|
import { dispatchSocketMessage } from './lib/socketDispatch.js';
|
|
49
50
|
import { pickActiveGame, isGameAvailable } from './lib/pickActiveGame.js';
|
|
50
51
|
import { readCoreAbi, dispatchCoreOp, ABI_UNKNOWN } from '../lib/coreAbi.js';
|
|
@@ -81,6 +82,9 @@ import LobbyAuthCtrl from './components/controller/LobbyAuth.js';
|
|
|
81
82
|
import GamesModel from './components/model/Games.js';
|
|
82
83
|
import GamesView from './components/view/Games.js';
|
|
83
84
|
import GamesCtrl from './components/controller/Games.js';
|
|
85
|
+
import ClientReportsModel from './components/model/ClientReports.js';
|
|
86
|
+
import ClientReportsView from './components/view/ClientReports.js';
|
|
87
|
+
import ClientReportsCtrl from './components/controller/ClientReports.js';
|
|
84
88
|
import BakingProvider from './providers/BakingProvider.js';
|
|
85
89
|
import DependencyProvider from './providers/DependencyProvider.js';
|
|
86
90
|
import { HOT_FLAGS } from '../config/opcodes.js';
|
|
@@ -120,6 +124,7 @@ let gameStyleNode = null;
|
|
|
120
124
|
// переключении в лобби
|
|
121
125
|
function bindActiveGame(manifest, plugin) {
|
|
122
126
|
activeGameManifest = manifest;
|
|
127
|
+
syncDiagnosticsGame();
|
|
123
128
|
clientPlugin = plugin;
|
|
124
129
|
|
|
125
130
|
if (!gameStyleNode) {
|
|
@@ -147,6 +152,32 @@ const boot = injectedBoot ?? (await resolveBootConfig());
|
|
|
147
152
|
const bootMode = boot.mode;
|
|
148
153
|
const isLobbyMode = bootMode === 'lobby';
|
|
149
154
|
|
|
155
|
+
// журнал клиентских ошибок (plan/client-reports): ставится как можно
|
|
156
|
+
// раньше, чтобы ловить и сбои самого старта. Лобби и dedicated шлют на
|
|
157
|
+
// свой бокс; SDK (solo) — только если встраивающий передал reportUrl
|
|
158
|
+
const diagnostics = createDiagnostics({
|
|
159
|
+
url:
|
|
160
|
+
bootMode === 'solo' ? (boot.reportUrl ?? null) : lobbyConfig.clientReportUrl,
|
|
161
|
+
context: {
|
|
162
|
+
mode: bootMode,
|
|
163
|
+
role: 'client',
|
|
164
|
+
gameId: null,
|
|
165
|
+
gameVersion: null,
|
|
166
|
+
page: location.pathname.slice(0, 128),
|
|
167
|
+
userAgent: navigator.userAgent.slice(0, 256),
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
diagnostics.install(window);
|
|
172
|
+
|
|
173
|
+
// игра в контексте отчётов — после каждой смены активного манифеста
|
|
174
|
+
function syncDiagnosticsGame() {
|
|
175
|
+
diagnostics.setContext({
|
|
176
|
+
gameId: activeGameManifest?.id ?? null,
|
|
177
|
+
gameVersion: activeGameManifest?.version ?? null,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
150
181
|
// точка монтирования игрового интерфейса: в lobby-режиме разметку даёт pug и
|
|
151
182
|
// каркас ничего не делает, в solo — собирается в контейнере SDK
|
|
152
183
|
const gameContainer = boot.container ?? document.body;
|
|
@@ -157,6 +188,7 @@ try {
|
|
|
157
188
|
if (boot.manifest) {
|
|
158
189
|
// SDK передаёт манифест-подобный объект в памяти — каталога мастера нет
|
|
159
190
|
activeGameManifest = boot.manifest;
|
|
191
|
+
syncDiagnosticsGame();
|
|
160
192
|
gamesManifest = [activeGameManifest];
|
|
161
193
|
} else {
|
|
162
194
|
gamesManifest = await manifestPromise;
|
|
@@ -170,6 +202,7 @@ try {
|
|
|
170
202
|
try {
|
|
171
203
|
// недоступная игра активной быть не может (lib/pickActiveGame.js)
|
|
172
204
|
activeGameManifest = pickActiveGame(gamesManifest, boot.gameId);
|
|
205
|
+
syncDiagnosticsGame();
|
|
173
206
|
} catch (e) {
|
|
174
207
|
// «каталог непустой, но играбельного в нём нет» (движок обновили, все
|
|
175
208
|
// опубликованные игры просят возможность, которой в нём уже нет) — для
|
|
@@ -436,6 +469,14 @@ socketMethods[PS_CONFIG_DATA] = async data => {
|
|
|
436
469
|
// part, объявивший сервис в componentDependencies, строит URL сам —
|
|
437
470
|
// движок не знает ни имён файлов, ни их раскладки внутри пакета
|
|
438
471
|
assetsBase: activeGameManifest.assetsBase,
|
|
472
|
+
// журнал клиентских ошибок для партов игры (plan/client-reports):
|
|
473
|
+
// warn(code, details) — своё предупреждение, capture(error) — пойманная
|
|
474
|
+
// ошибка. Сервис опционален: игра не пишет его в requires
|
|
475
|
+
diagnostics: {
|
|
476
|
+
warn: (code, details) =>
|
|
477
|
+
diagnostics.warn(code, details, { source: 'plugin' }),
|
|
478
|
+
capture: error => diagnostics.capture(error, { source: 'plugin' }),
|
|
479
|
+
},
|
|
439
480
|
};
|
|
440
481
|
|
|
441
482
|
// если есть данные для запекания компонентов
|
|
@@ -1430,6 +1471,7 @@ function handleDisconnect(closeCode) {
|
|
|
1430
1471
|
hostController?.destroy();
|
|
1431
1472
|
hostConnections = null;
|
|
1432
1473
|
hostController = null; // останавливает и reconnect-петлю сигналинга
|
|
1474
|
+
diagnostics.setContext({ role: 'client' });
|
|
1433
1475
|
hostRegistration = null;
|
|
1434
1476
|
|
|
1435
1477
|
// solo: хост крутится в этом же потоке — его таймеры переживут матч
|
|
@@ -1593,8 +1635,11 @@ async function connectAsHost(room) {
|
|
|
1593
1635
|
console.warn('[worker] master manifest unavailable, using bundled:', e);
|
|
1594
1636
|
}
|
|
1595
1637
|
|
|
1638
|
+
diagnostics.setContext({ role: 'host' });
|
|
1639
|
+
|
|
1596
1640
|
hostController = new HostController(room, {
|
|
1597
1641
|
workerUrl,
|
|
1642
|
+
diagnostics,
|
|
1598
1643
|
onReady: readyMsg => {
|
|
1599
1644
|
currentMapName = readyMsg?.mapName;
|
|
1600
1645
|
|
|
@@ -2288,6 +2333,17 @@ function initLobby() {
|
|
|
2288
2333
|
// запись — auth-сервис, перечитывая роль из БД
|
|
2289
2334
|
games.setAdmin(lobbyAuthModel.getRole() === 'admin');
|
|
2290
2335
|
|
|
2336
|
+
// журнал клиентских ошибок (plan/client-reports, этап 5): кнопку «Errors»
|
|
2337
|
+
// видит только админ; фильтр по игре — каталог вкладки
|
|
2338
|
+
const clientReportsModel = new ClientReportsModel(lobbyConfig.clientReports, () =>
|
|
2339
|
+
lobbyAuthModel.getToken(),
|
|
2340
|
+
);
|
|
2341
|
+
const clientReportsView = new ClientReportsView(clientReportsModel, lobbyConfig.clientReports);
|
|
2342
|
+
const clientReports = new ClientReportsCtrl(clientReportsModel, clientReportsView);
|
|
2343
|
+
|
|
2344
|
+
clientReportsView.setGames([...gamesById.values()]);
|
|
2345
|
+
clientReports.setAdmin(lobbyAuthModel.getRole() === 'admin');
|
|
2346
|
+
|
|
2291
2347
|
// «Test»: манифест застейдженной версии кладётся в каталог вкладки, и
|
|
2292
2348
|
// админ поднимает по нему комнату обычной кнопкой Create server
|
|
2293
2349
|
games.publisher.on('staged', ({ manifest }) => {
|
|
@@ -35,14 +35,18 @@ export default class HostController {
|
|
|
35
35
|
* (WASM/конфиг): комната не поднялась, нужно вернуть пользователя в лобби.
|
|
36
36
|
* @param {Function} [opts.onMapChange] - смена карты в комнате (голосование/
|
|
37
37
|
* таймер) — для актуализации mapName у мастера.
|
|
38
|
+
* @param {Object} [opts.diagnostics] - журнал клиентских ошибок
|
|
39
|
+
* (lib/diagnostics.js, plan/client-reports): туда уходят ошибки Worker'а.
|
|
38
40
|
*/
|
|
39
41
|
constructor(
|
|
40
42
|
room,
|
|
41
|
-
{ workerFactory, workerUrl, onReady, onError, onMapChange } = {},
|
|
43
|
+
{ workerFactory, workerUrl, onReady, onError, onMapChange, diagnostics } = {},
|
|
42
44
|
) {
|
|
43
45
|
this._room = room;
|
|
44
46
|
this._workerFactory = workerFactory;
|
|
47
|
+
this._diagnostics = diagnostics ?? null;
|
|
45
48
|
this._worker = this._createWorker(workerUrl);
|
|
49
|
+
this._watchWorkerErrors(this._worker);
|
|
46
50
|
|
|
47
51
|
this._onReady = onReady;
|
|
48
52
|
this._onError = onError;
|
|
@@ -76,6 +80,22 @@ export default class HostController {
|
|
|
76
80
|
});
|
|
77
81
|
}
|
|
78
82
|
|
|
83
|
+
// журнал клиентских ошибок (plan/client-reports): неперехваченная ошибка
|
|
84
|
+
// Worker'а приходит сюда. preventDefault не зовётся — консольный вывод
|
|
85
|
+
// браузера остаётся как есть
|
|
86
|
+
_watchWorkerErrors(worker) {
|
|
87
|
+
worker.onerror = event =>
|
|
88
|
+
this._diagnostics?.capture(event.error ?? event.message, {
|
|
89
|
+
source: 'host-worker',
|
|
90
|
+
kind: 'worker',
|
|
91
|
+
});
|
|
92
|
+
worker.onmessageerror = () =>
|
|
93
|
+
this._diagnostics?.capture('messageerror', {
|
|
94
|
+
source: 'host-worker',
|
|
95
|
+
kind: 'worker',
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
79
99
|
// регистрирует клиента и (при готовности) поднимает его соединение в Worker'е
|
|
80
100
|
open(socketId, { onMessage, onClose }) {
|
|
81
101
|
this._deliveries.set(socketId, { onMessage, onClose });
|
|
@@ -311,18 +331,41 @@ export default class HostController {
|
|
|
311
331
|
);
|
|
312
332
|
|
|
313
333
|
next.onmessage = e => this._onNextWorkerMessage(e.data);
|
|
334
|
+
this._watchWorkerErrors(next);
|
|
314
335
|
next.postMessage({ type: 'init', room: this._room, handoff: state });
|
|
315
336
|
}
|
|
316
337
|
|
|
317
|
-
// сообщения нового Worker'а до завершения свопа: ждём только ready/error
|
|
338
|
+
// сообщения нового Worker'а до завершения свопа: ждём только ready/error,
|
|
339
|
+
// но его сбои (в том числе провал init) попадают в журнал ошибок так же,
|
|
340
|
+
// как у рабочего Worker'а
|
|
318
341
|
_onNextWorkerMessage(msg) {
|
|
319
342
|
if (msg.type === 'ready') {
|
|
320
343
|
this._finishSwap();
|
|
321
344
|
} else if (msg.type === 'error') {
|
|
345
|
+
this._reportWorkerError(msg);
|
|
322
346
|
this._abortSwap(msg.message);
|
|
347
|
+
} else if (msg.type === 'diagnostic') {
|
|
348
|
+
this._reportWorkerDiagnostic(msg);
|
|
323
349
|
}
|
|
324
350
|
}
|
|
325
351
|
|
|
352
|
+
// сбой init Worker'а — в журнал клиентских ошибок (plan/client-reports)
|
|
353
|
+
_reportWorkerError(msg) {
|
|
354
|
+
this._diagnostics?.capture(new Error(msg.message), {
|
|
355
|
+
source: 'host-worker',
|
|
356
|
+
kind: 'error',
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// необработанный reject внутри Worker'а (host.worker.js) — только в
|
|
361
|
+
// журнал: откат эстафеты завязан на 'error', не на него
|
|
362
|
+
_reportWorkerDiagnostic(msg) {
|
|
363
|
+
this._diagnostics?.capture(
|
|
364
|
+
{ message: msg.message, stack: msg.stack },
|
|
365
|
+
{ source: 'host-worker', kind: msg.kind },
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
|
|
326
369
|
// новый Worker готов: переподключить клиентов, дослать накопленное,
|
|
327
370
|
// завершить эстафету и погасить старый Worker
|
|
328
371
|
_finishSwap() {
|
|
@@ -383,9 +426,14 @@ export default class HostController {
|
|
|
383
426
|
break;
|
|
384
427
|
|
|
385
428
|
case 'error':
|
|
429
|
+
this._reportWorkerError(msg);
|
|
386
430
|
this._onError?.(msg);
|
|
387
431
|
break;
|
|
388
432
|
|
|
433
|
+
case 'diagnostic':
|
|
434
|
+
this._reportWorkerDiagnostic(msg);
|
|
435
|
+
break;
|
|
436
|
+
|
|
389
437
|
case 'map_changed':
|
|
390
438
|
this._onMapChange?.(msg.mapName);
|
|
391
439
|
break;
|