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.
@@ -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
+ }
@@ -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;