/*! * Co-browsing widget. * Подключается на любой сайт одним тегом: *
* * * Все параметры опциональны, дефолты вычисляются от src скрипта. */ (function () { 'use strict'; // Не подключаемся дважды на одной странице if (window.__cobrowseWidgetLoaded) return; window.__cobrowseWidgetLoaded = true; // ========================================================== // 1. Определяем, откуда загружен сам виджет // ========================================================== var selfScript = document.currentScript || (function () { var scripts = document.getElementsByTagName('script'); return scripts[scripts.length - 1]; })(); var scriptSrc = selfScript && selfScript.src ? selfScript.src : ''; var origin = ''; try { origin = new URL(scriptSrc, location.href).origin; } catch (_) {} // ========================================================== // 2. Параметры (можно переопределить через data-* атрибуты) // ========================================================== var cfg = { // WS-эндпоинт. По умолчанию — тот же origin, что и скрипт. ws: selfScript.getAttribute('data-ws') || (origin.replace(/^http/, 'ws') + '/support/ws/'), // URL библиотеки rrweb. По умолчанию — CDN. rrweb: selfScript.getAttribute('data-rrweb') || 'https://cdn.jsdelivr.net/npm/rrweb@2.0.0-alpha.4/dist/rrweb-all.min.js', // ID элемента, в который писать код сессии. По умолчанию — supportID. target: selfScript.getAttribute('data-target') || 'supportID', // Ключ для хранения кода сессии в sessionStorage. storageKey: selfScript.getAttribute('data-storage-key') || 'cobrowse.sessionCode', // Автоматически стартовать после загрузки rrweb. autoStart: selfScript.getAttribute('data-auto-start') !== 'false', // Реконнект. reconnectMin: 1000, reconnectMax: 30000, }; // ========================================================== // 3. Код сессии (генерируется один раз на вкладку) // ========================================================== function generateCode(len) { var alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; var arr = new Uint8Array(len); (window.crypto || window.msCrypto).getRandomValues(arr); var out = ''; for (var i = 0; i < len; i++) out += alphabet[arr[i] % alphabet.length]; return out; } var sessionCode = null; try { sessionCode = sessionStorage.getItem(cfg.storageKey); } catch (_) {} if (!sessionCode) { sessionCode = generateCode(6); try { sessionStorage.setItem(cfg.storageKey, sessionCode); } catch (_) {} } // ========================================================== // 4. Куда писать код сессии // ========================================================== function renderSessionCode() { var el = document.getElementById(cfg.target); if (el) el.textContent = sessionCode; } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', renderSessionCode); } else { renderSessionCode(); } // Если #supportID появится позже — попробуем ещё раз при загрузке окна. window.addEventListener('load', renderSessionCode); // ========================================================== // 5. Загрузка rrweb (если ещё не загружен) // ========================================================== function loadRrweb(cb) { if (window.rrweb && typeof window.rrweb.record === 'function') { return cb(null); } var s = document.createElement('script'); s.src = cfg.rrweb; s.async = true; s.onload = function () { cb(null); }; s.onerror = function () { cb(new Error('Не удалось загрузить rrweb: ' + cfg.rrweb)); }; document.head.appendChild(s); } // ========================================================== // 6. Перехват console.log / console.error // ========================================================== var wsRef = { current: null }; function wsSend(obj) { var ws = wsRef.current; if (ws && ws.readyState === 1 /* OPEN */) { try { ws.send(JSON.stringify(obj)); return true; } catch (_) { return false; } } return false; } function serializeOne(v) { try { if (v === undefined) return 'undefined'; if (v === null) return 'null'; var t = typeof v; if (t === 'string') return v.length > 4000 ? v.slice(0, 4000) + '…' : v; if (t === 'number' || t === 'boolean' || t === 'bigint') return String(v); if (t === 'function') return '[Function ' + (v.name || 'anonymous') + ']'; if (t === 'symbol') return v.toString(); if (v instanceof Error) return v.stack || v.message || String(v); if (t === 'object') { var seen = new WeakSet(); var str = JSON.stringify(v, function (k, val) { if (typeof val === 'object' && val !== null) { if (seen.has(val)) return '[Circular]'; seen.add(val); } if (typeof val === 'function') return '[Function ' + (val.name || 'anonymous') + ']'; return val; }); if (str === undefined) return String(v); return str.length > 4000 ? str.slice(0, 4000) + '…' : str; } return String(v); } catch (e) { return '[unserializable: ' + (e && e.message ? e.message : 'error') + ']'; } } function installConsoleHook() { if (window.__cobrowseConsoleHooked) return; window.__cobrowseConsoleHooked = true; var origLog = console.log.bind(console); var origError = console.error.bind(console); function emit(level, args) { try { var out = []; for (var i = 0; i < args.length; i++) out.push(serializeOne(args[i])); wsSend({ type: 'console-event', level: level, args: out, ts: Date.now() }); } catch (_) { /* не ломаем страницу */ } } console.log = function () { origLog.apply(null, arguments); emit('log', arguments); }; console.error = function () { origError.apply(null, arguments); emit('error', arguments); }; } // ========================================================== // 7. WebSocket + авто-реконнект // ========================================================== var reconnectDelay = cfg.reconnectMin; var stopRecording = null; var started = false; function connect() { var ws; try { ws = new WebSocket(cfg.ws); } catch (e) { return scheduleReconnect(); } wsRef.current = ws; ws.addEventListener('open', function () { reconnectDelay = cfg.reconnectMin; wsSend({ type: 'register-session', sessionCode: sessionCode }); }); ws.addEventListener('message', function (e) { var msg; try { msg = JSON.parse(e.data); } catch (_) { return; } if (msg.type === 'registered') { if (!started) { started = true; startRecording(); } } }); ws.addEventListener('close', function () { wsRef.current = null; scheduleReconnect(); }); ws.addEventListener('error', function () { // Здесь ничего не делаем — close всё равно сработает }); } function scheduleReconnect() { setTimeout(connect, reconnectDelay); reconnectDelay = Math.min(reconnectDelay * 2, cfg.reconnectMax); } // ========================================================== // 8. rrweb-запись // ========================================================== function startRecording() { if (stopRecording) return; stopRecording = window.rrweb.record({ emit: function (event) { wsSend({ type: 'rrweb-event', event: event }); }, // Безопасность: пароли/телефоны/почты не уйдут оператору. // Если нужно, наоборот, показывать всё — переопределите через data-*. maskAllInputs: false, maskInputOptions: { password: true, email: true, tel: true, number: true, }, blockClass: 'no-cobrowse', }); } // ========================================================== // 9. Публичный API (на случай, если сайт захочет чем-то управлять) // ========================================================== window.Cobrowse = { getSessionCode: function () { return sessionCode; }, send: function (obj) { return wsSend(obj); }, // Позволяет сайту самому решить, когда стартовать // (если data-auto-start="false") start: function () { installConsoleHook(); connect(); }, stop: function () { if (stopRecording) { try { stopRecording(); } catch (_) {} stopRecording = null; } if (wsRef.current) { try { wsRef.current.close(1000, 'widget stop'); } catch (_) {} wsRef.current = null; } started = false; }, }; // ========================================================== // 10. Старт // ========================================================== if (cfg.autoStart) { installConsoleHook(); loadRrweb(function (err) { if (err) { console.warn('[cobrowse-widget]', err.message); return; } connect(); }); } })();