vimp-engine 0.34.8 → 0.35.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.
@@ -0,0 +1,456 @@
1
+ // Журнал клиентских ошибок (plan/client-reports, этап 3): неперехваченные
2
+ // ошибки и отклонения страницы, ошибки Worker'а хоста, нарушения CSP и явные
3
+ // warn/capture плагинов уходят на свой же бокс (POST /client-reports), а тот
4
+ // пересылает их в центральный журнал.
5
+ //
6
+ // Репортёр не должен стать новым источником падений и шума: каждая
7
+ // публичная функция глушит свои исключения, повтор известной ошибки — только
8
+ // счётчик, разных ошибок за сессию не больше maxKeysPerSession, отправка —
9
+ // пачками с дельтами счётчиков: первая через 2 с, следующие не чаще раза в
10
+ // 10 с. console.* не перехватывается осознанно: там шум и случайные данные в
11
+ // аргументах.
12
+
13
+ // обрезки — те же числа, что у приёма на боксе (stage_2.md, контракт)
14
+ const MAX_MESSAGE = 500;
15
+ const MAX_STACK = 4000;
16
+ const MAX_CODE = 64;
17
+ const MAX_DETAILS_BYTES = 2048;
18
+
19
+ // пачка: не больше 10 записей (лимит бокса) и заметно меньше его 16 КБ тела
20
+ const MAX_BATCH_ITEMS = 10;
21
+ const MAX_BATCH_BYTES = 15000;
22
+ const SHRUNK_STACK = 1000;
23
+
24
+ const CODE_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/i;
25
+
26
+ // кадр стека с позицией «:строка:колонка» — он и различает одинаковые
27
+ // сообщения из разных мест
28
+ const FRAME_RE = /:\d+:\d+/;
29
+
30
+ // расширения браузера игрока внедряют скрипты и стили — их нарушения CSP
31
+ // утопили бы журнал в чужом шуме
32
+ const EXTENSION_PREFIXES = [
33
+ 'chrome-extension:',
34
+ 'moz-extension:',
35
+ 'safari-extension:',
36
+ 'safari-web-extension:',
37
+ ];
38
+
39
+ // служебные значения blockedURI — не URL, берутся как есть
40
+ const CSP_KEYWORDS = new Set(['', 'inline', 'eval', 'wasm-eval', 'data', 'blob']);
41
+
42
+ const encoder = new TextEncoder();
43
+
44
+ function byteLength(str) {
45
+ return encoder.encode(str).length;
46
+ }
47
+
48
+ function makeSessionId() {
49
+ try {
50
+ if (typeof crypto !== 'undefined' && crypto.randomUUID) {
51
+ return crypto.randomUUID();
52
+ }
53
+ } catch {
54
+ // нет безопасного контекста — ниже фолбэк
55
+ }
56
+
57
+ return (
58
+ Math.random().toString(16).slice(2) + Math.random().toString(16).slice(2)
59
+ );
60
+ }
61
+
62
+ function safeString(value) {
63
+ try {
64
+ return String(value);
65
+ } catch {
66
+ return '[unprintable]';
67
+ }
68
+ }
69
+
70
+ function topFrame(stack) {
71
+ if (!stack) {
72
+ return '';
73
+ }
74
+
75
+ return stack.split('\n').find(line => FRAME_RE.test(line))?.trim() ?? '';
76
+ }
77
+
78
+ // снимок деталей: простой объект ≤ 2048 байт JSON, иначе пометка усечения
79
+ function normalizeDetails(details) {
80
+ if (details === null || details === undefined) {
81
+ return null;
82
+ }
83
+
84
+ try {
85
+ const json = JSON.stringify(details);
86
+
87
+ if (
88
+ typeof details !== 'object' ||
89
+ json === undefined ||
90
+ byteLength(json) > MAX_DETAILS_BYTES
91
+ ) {
92
+ return { truncated: true };
93
+ }
94
+
95
+ return JSON.parse(json);
96
+ } catch {
97
+ return { truncated: true };
98
+ }
99
+ }
100
+
101
+ // URL без query и hash: в них бывают токены и прочие личные данные
102
+ function stripUrl(value) {
103
+ const str = typeof value === 'string' ? value : '';
104
+
105
+ if (CSP_KEYWORDS.has(str)) {
106
+ return str;
107
+ }
108
+
109
+ try {
110
+ const url = new URL(str);
111
+
112
+ return url.origin + url.pathname;
113
+ } catch {
114
+ return str.split(/[?#]/)[0];
115
+ }
116
+ }
117
+
118
+ function isExtensionUrl(value) {
119
+ return (
120
+ typeof value === 'string' &&
121
+ EXTENSION_PREFIXES.some(prefix => value.startsWith(prefix))
122
+ );
123
+ }
124
+
125
+ // транспорт по умолчанию: beacon переживает закрытие вкладки; нет его или
126
+ // очередь переполнена — fetch с keepalive. Ответ не читается
127
+ function defaultSend(url, json) {
128
+ if (typeof navigator !== 'undefined' && navigator.sendBeacon) {
129
+ try {
130
+ if (navigator.sendBeacon(url, new Blob([json], { type: 'application/json' }))) {
131
+ return;
132
+ }
133
+ } catch {
134
+ // старые Chromium бросали SecurityError на Blob с application/json
135
+ // (crbug.com/490015) — тогда запасной fetch
136
+ }
137
+ }
138
+
139
+ fetch(url, {
140
+ method: 'POST',
141
+ body: json,
142
+ headers: { 'content-type': 'application/json' },
143
+ keepalive: true,
144
+ credentials: 'same-origin',
145
+ }).catch(() => {});
146
+ }
147
+
148
+ function noop() {}
149
+
150
+ /**
151
+ * Создаёт репортёр клиентских ошибок.
152
+ * @param {Object} opts
153
+ * @param {string|null} opts.url - адрес приёма; null — репортёр выключен
154
+ * @param {Object} [opts.context] - { mode, role, gameId, gameVersion, page, userAgent }
155
+ * @param {Function} [opts.send] - (url, json) => void; по умолчанию beacon/fetch
156
+ * @param {Function} [opts.now]
157
+ * @param {number} [opts.maxKeysPerSession=50]
158
+ * @param {number} [opts.flushDelayMs=2000] - задержка первой отправки
159
+ * @param {number} [opts.minIntervalMs=10000] - интервал между отправками по
160
+ * таймеру; прямой flush() его не ждёт
161
+ * @returns {{ capture, warn, flush, setContext, install }}
162
+ */
163
+ export function createDiagnostics({
164
+ url,
165
+ context = {},
166
+ send = defaultSend,
167
+ now = Date.now,
168
+ maxKeysPerSession = 50,
169
+ flushDelayMs = 2000,
170
+ minIntervalMs = 10000,
171
+ } = {}) {
172
+ if (!url) {
173
+ return {
174
+ capture: noop,
175
+ warn: noop,
176
+ flush: noop,
177
+ setContext: noop,
178
+ install: () => noop,
179
+ };
180
+ }
181
+
182
+ const sessionId = makeSessionId();
183
+ const ctx = { ...context };
184
+
185
+ // ключ дедупа → запись со счётчиками: count — всего за сессию, sentCount —
186
+ // сколько уже отправлено, deltaFirstAt — первое время в неотправленной дельте
187
+ const records = new Map();
188
+
189
+ let capWarned = false;
190
+ let timer = null;
191
+ let lastSentAt = null;
192
+
193
+ // ошибка внутри самого репортёра не должна порождать новый отчёт
194
+ let busy = false;
195
+
196
+ function record(item) {
197
+ const key = `${item.source}|${item.kind}|${item.code ?? item.message}|${topFrame(item.stack)}`;
198
+ const at = now();
199
+ const known = records.get(key);
200
+
201
+ if (known) {
202
+ known.count += 1;
203
+ known.lastAt = at;
204
+
205
+ if (known.count - known.sentCount === 1) {
206
+ known.deltaFirstAt = at;
207
+ }
208
+ } else {
209
+ if (records.size >= maxKeysPerSession) {
210
+ if (!capWarned) {
211
+ capWarned = true;
212
+ console.warn('[vimp] diagnostics: session cap reached');
213
+ }
214
+
215
+ return;
216
+ }
217
+
218
+ records.set(key, {
219
+ item,
220
+ count: 1,
221
+ sentCount: 0,
222
+ deltaFirstAt: at,
223
+ lastAt: at,
224
+ });
225
+ }
226
+
227
+ schedule();
228
+ }
229
+
230
+ function schedule() {
231
+ if (timer !== null) {
232
+ return;
233
+ }
234
+
235
+ // первая отправка — через flushDelayMs, следующие — не чаще
236
+ // minIntervalMs: бокс режет приём лимитом запросов с адреса, и 429
237
+ // молча съедал бы приросты счётчиков непрерывно повторяющейся ошибки
238
+ const sinceLast = lastSentAt === null ? Infinity : now() - lastSentAt;
239
+
240
+ timer = setTimeout(flush, Math.max(flushDelayMs, minIntervalMs - sinceLast));
241
+ }
242
+
243
+ function guarded(fn) {
244
+ if (busy) {
245
+ return;
246
+ }
247
+
248
+ busy = true;
249
+
250
+ try {
251
+ fn();
252
+ } catch {
253
+ // репортёр молчит о своих сбоях — иначе он сам стал бы шумом
254
+ } finally {
255
+ busy = false;
256
+ }
257
+ }
258
+
259
+ function capture(error, { source = 'client', kind = 'error' } = {}) {
260
+ guarded(() => {
261
+ const raw =
262
+ error !== null && error !== undefined && error.message !== undefined
263
+ ? error.message
264
+ : error;
265
+ const stack =
266
+ error !== null && error !== undefined && typeof error.stack === 'string'
267
+ ? error.stack.slice(0, MAX_STACK)
268
+ : null;
269
+
270
+ record({
271
+ kind,
272
+ source,
273
+ code: null,
274
+ message: safeString(raw).slice(0, MAX_MESSAGE),
275
+ stack,
276
+ details: null,
277
+ });
278
+ });
279
+ }
280
+
281
+ function warn(code, details = null, { source = 'plugin', message } = {}) {
282
+ guarded(() => {
283
+ const safeCode =
284
+ typeof code === 'string' && CODE_RE.test(code)
285
+ ? code.slice(0, MAX_CODE)
286
+ : 'invalid-code';
287
+ const stack = new Error().stack;
288
+
289
+ record({
290
+ kind: 'warn',
291
+ source,
292
+ code: safeCode,
293
+ message: safeString(message ?? safeCode).slice(0, MAX_MESSAGE),
294
+ stack: typeof stack === 'string' ? stack.slice(0, MAX_STACK) : null,
295
+ details: normalizeDetails(details),
296
+ });
297
+ });
298
+ }
299
+
300
+ // нарушение CSP (только в проде: CSP ставит мастер) — одно правило,
301
+ // сработавшее тысячу раз, одна запись со счётчиком
302
+ function reportCsp(event) {
303
+ guarded(() => {
304
+ if (isExtensionUrl(event.blockedURI) || isExtensionUrl(event.sourceFile)) {
305
+ return;
306
+ }
307
+
308
+ const blocked = stripUrl(event.blockedURI);
309
+
310
+ record({
311
+ kind: 'csp',
312
+ source: 'client',
313
+ code: null,
314
+ message: `CSP ${event.effectiveDirective} blocked ${blocked}`.slice(
315
+ 0,
316
+ MAX_MESSAGE,
317
+ ),
318
+ stack: null,
319
+ details: normalizeDetails({
320
+ directive: event.effectiveDirective,
321
+ disposition: event.disposition,
322
+ sourceFile: stripUrl(event.sourceFile),
323
+ line: event.lineNumber,
324
+ column: event.columnNumber,
325
+ sample: (event.sample || '').slice(0, 40),
326
+ }),
327
+ });
328
+ });
329
+ }
330
+
331
+ function body(items) {
332
+ return JSON.stringify({ v: 1, sessionId, context: ctx, items });
333
+ }
334
+
335
+ // пачка больше предела: сперва без деталей, затем с короткими стеками;
336
+ // не помогло — по одной записи
337
+ function sendBatch(items) {
338
+ let json = body(items);
339
+
340
+ if (byteLength(json) > MAX_BATCH_BYTES) {
341
+ items = items.map(item => ({ ...item, details: null }));
342
+ json = body(items);
343
+ }
344
+
345
+ if (byteLength(json) > MAX_BATCH_BYTES) {
346
+ items = items.map(item => ({
347
+ ...item,
348
+ stack: item.stack ? item.stack.slice(0, SHRUNK_STACK) : item.stack,
349
+ }));
350
+ json = body(items);
351
+ }
352
+
353
+ if (byteLength(json) > MAX_BATCH_BYTES && items.length > 1) {
354
+ for (const item of items) {
355
+ send(url, body([item]));
356
+ }
357
+
358
+ return;
359
+ }
360
+
361
+ send(url, json);
362
+ }
363
+
364
+ // уходят только записи, у которых с прошлой отправки вырос счётчик, —
365
+ // приростом, а не итогом (бокс складывает)
366
+ function flush() {
367
+ if (timer !== null) {
368
+ clearTimeout(timer);
369
+ timer = null;
370
+ }
371
+
372
+ guarded(() => {
373
+ const items = [];
374
+
375
+ for (const rec of records.values()) {
376
+ const delta = rec.count - rec.sentCount;
377
+
378
+ if (delta > 0) {
379
+ items.push({
380
+ ...rec.item,
381
+ count: delta,
382
+ firstAt: rec.deltaFirstAt,
383
+ lastAt: rec.lastAt,
384
+ });
385
+ rec.sentCount = rec.count;
386
+ }
387
+ }
388
+
389
+ if (items.length > 0) {
390
+ lastSentAt = now();
391
+ }
392
+
393
+ for (let i = 0; i < items.length; i += MAX_BATCH_ITEMS) {
394
+ sendBatch(items.slice(i, i + MAX_BATCH_ITEMS));
395
+ }
396
+ });
397
+ }
398
+
399
+ // смена контекста (игра, роль): накопленное уходит СО СВОИМ контекстом —
400
+ // иначе отчёт игры A, отправленный уже под игрой B, получил бы на боксе
401
+ // отпечаток и source maps игры B
402
+ function setContext(patch) {
403
+ try {
404
+ if (Object.keys(patch).some(key => ctx[key] !== patch[key])) {
405
+ flush();
406
+ }
407
+
408
+ Object.assign(ctx, patch);
409
+ } catch {
410
+ // контекст — вспомогательный, его сбой отчёты не останавливает
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Вешает глобальные перехватчики.
416
+ * @param {Window} [target]
417
+ * @returns {Function} снятие перехватчиков
418
+ */
419
+ function install(target = window) {
420
+ // ошибки загрузки <img>/<script> приходят обычным Event на элементе —
421
+ // это сбои ресурсов, а не исключения
422
+ const onError = event => {
423
+ if (typeof ErrorEvent !== 'undefined' && event instanceof ErrorEvent) {
424
+ capture(event.error ?? event.message, { kind: 'error' });
425
+ }
426
+ };
427
+ const onRejection = event => {
428
+ capture(event.reason, { kind: 'rejection' });
429
+ };
430
+ const onPageHide = () => flush();
431
+ const doc = target.document;
432
+
433
+ try {
434
+ target.addEventListener('error', onError);
435
+ target.addEventListener('unhandledrejection', onRejection);
436
+ target.addEventListener('pagehide', onPageHide);
437
+ // событие всплывает к документу
438
+ doc?.addEventListener('securitypolicyviolation', reportCsp);
439
+ } catch {
440
+ // нет цели — просто не ловим
441
+ }
442
+
443
+ return () => {
444
+ try {
445
+ target.removeEventListener('error', onError);
446
+ target.removeEventListener('unhandledrejection', onRejection);
447
+ target.removeEventListener('pagehide', onPageHide);
448
+ doc?.removeEventListener('securitypolicyviolation', reportCsp);
449
+ } catch {
450
+ // снятие не должно ронять вызывающего
451
+ }
452
+ };
453
+ }
454
+
455
+ return { capture, warn, flush, setContext, install };
456
+ }
@@ -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 }) => {
@@ -20,6 +20,24 @@ const SWAP_QUEUE_LIMIT = 2000;
20
20
  // предел ожидания ответа Worker'а на отладочный запрос (dump/запись)
21
21
  const DEBUG_TIMEOUT = 5000;
22
22
 
23
+ // ErrorEvent Worker'а в главном потоке: `error` там null (исключение между
24
+ // потоками не клонируется) — есть только message и filename/lineno/colno.
25
+ // Кадр собирается из них: без него у отчёта нет верхнего кадра, то есть ни
26
+ // различимого отпечатка, ни расшифровки по source map. Пустой message
27
+ // (Worker не загрузился) бокс отбросил бы — отсюда запасной текст
28
+ function workerErrorReport(event) {
29
+ if (typeof event?.error?.stack === 'string') {
30
+ return event.error;
31
+ }
32
+
33
+ const message = event?.message || 'Worker error';
34
+ const stack = event?.filename
35
+ ? `${message}\n at ${event.filename}:${event.lineno ?? 0}:${event.colno ?? 0}`
36
+ : null;
37
+
38
+ return { message, stack };
39
+ }
40
+
23
41
  export default class HostController {
24
42
  /**
25
43
  * @param {Object} room - настройки комнаты (имя/карта/лимит/таймеры).
@@ -35,14 +53,18 @@ export default class HostController {
35
53
  * (WASM/конфиг): комната не поднялась, нужно вернуть пользователя в лобби.
36
54
  * @param {Function} [opts.onMapChange] - смена карты в комнате (голосование/
37
55
  * таймер) — для актуализации mapName у мастера.
56
+ * @param {Object} [opts.diagnostics] - журнал клиентских ошибок
57
+ * (lib/diagnostics.js, plan/client-reports): туда уходят ошибки Worker'а.
38
58
  */
39
59
  constructor(
40
60
  room,
41
- { workerFactory, workerUrl, onReady, onError, onMapChange } = {},
61
+ { workerFactory, workerUrl, onReady, onError, onMapChange, diagnostics } = {},
42
62
  ) {
43
63
  this._room = room;
44
64
  this._workerFactory = workerFactory;
65
+ this._diagnostics = diagnostics ?? null;
45
66
  this._worker = this._createWorker(workerUrl);
67
+ this._watchWorkerErrors(this._worker);
46
68
 
47
69
  this._onReady = onReady;
48
70
  this._onError = onError;
@@ -76,6 +98,22 @@ export default class HostController {
76
98
  });
77
99
  }
78
100
 
101
+ // журнал клиентских ошибок (plan/client-reports): неперехваченная ошибка
102
+ // Worker'а приходит сюда. preventDefault не зовётся — консольный вывод
103
+ // браузера остаётся как есть
104
+ _watchWorkerErrors(worker) {
105
+ worker.onerror = event =>
106
+ this._diagnostics?.capture(workerErrorReport(event), {
107
+ source: 'host-worker',
108
+ kind: 'worker',
109
+ });
110
+ worker.onmessageerror = () =>
111
+ this._diagnostics?.capture('messageerror', {
112
+ source: 'host-worker',
113
+ kind: 'worker',
114
+ });
115
+ }
116
+
79
117
  // регистрирует клиента и (при готовности) поднимает его соединение в Worker'е
80
118
  open(socketId, { onMessage, onClose }) {
81
119
  this._deliveries.set(socketId, { onMessage, onClose });
@@ -311,18 +349,34 @@ export default class HostController {
311
349
  );
312
350
 
313
351
  next.onmessage = e => this._onNextWorkerMessage(e.data);
352
+ this._watchWorkerErrors(next);
314
353
  next.postMessage({ type: 'init', room: this._room, handoff: state });
315
354
  }
316
355
 
317
- // сообщения нового Worker'а до завершения свопа: ждём только ready/error
356
+ // сообщения нового Worker'а до завершения свопа: ждём только ready/error,
357
+ // но его сбои (в том числе провал init) попадают в журнал ошибок так же,
358
+ // как у рабочего Worker'а
318
359
  _onNextWorkerMessage(msg) {
319
360
  if (msg.type === 'ready') {
320
361
  this._finishSwap();
321
362
  } else if (msg.type === 'error') {
363
+ this._reportWorkerMessage(msg, 'error');
322
364
  this._abortSwap(msg.message);
365
+ } else if (msg.type === 'diagnostic') {
366
+ this._reportWorkerMessage(msg, msg.kind);
323
367
  }
324
368
  }
325
369
 
370
+ // сбой Worker'а, присланный сообщением ('error' — провал init,
371
+ // 'diagnostic' — необработанный reject): стек — самого Worker'а, а не
372
+ // главного потока
373
+ _reportWorkerMessage(msg, kind) {
374
+ this._diagnostics?.capture(
375
+ { message: msg.message, stack: msg.stack ?? null },
376
+ { source: 'host-worker', kind },
377
+ );
378
+ }
379
+
326
380
  // новый Worker готов: переподключить клиентов, дослать накопленное,
327
381
  // завершить эстафету и погасить старый Worker
328
382
  _finishSwap() {
@@ -383,9 +437,14 @@ export default class HostController {
383
437
  break;
384
438
 
385
439
  case 'error':
440
+ this._reportWorkerMessage(msg, 'error');
386
441
  this._onError?.(msg);
387
442
  break;
388
443
 
444
+ case 'diagnostic':
445
+ this._reportWorkerMessage(msg, msg.kind);
446
+ break;
447
+
389
448
  case 'map_changed':
390
449
  this._onMapChange?.(msg.mapName);
391
450
  break;