@bluebottle_gg/league-broadcast-client 1.1.1 → 1.2.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/dist/index.d.ts CHANGED
@@ -2975,6 +2975,10 @@ interface LeagueBroadcastClientConfig {
2975
2975
  cacheRoute?: string;
2976
2976
  useHttps?: boolean;
2977
2977
  autoConnect?: boolean;
2978
+ overlayHealth?: boolean | {
2979
+ enabled?: boolean;
2980
+ name?: string;
2981
+ };
2978
2982
  }
2979
2983
  interface IngameEventHandlers {
2980
2984
  onPlayerEvent?: (event: playerUpdateEvent) => void;
@@ -3029,6 +3033,7 @@ declare class LeagueBroadcastClient {
3029
3033
  private ingameWs;
3030
3034
  private preGameWs;
3031
3035
  private config;
3036
+ private _overlayHealth;
3032
3037
  private gameData;
3033
3038
  private gameState;
3034
3039
  private _isTestingEnvironment;
@@ -3082,6 +3087,11 @@ declare class LeagueBroadcastClient {
3082
3087
  * Disconnect from both WebSocket endpoints.
3083
3088
  */
3084
3089
  disconnect(): void;
3090
+ /**
3091
+ * Starts passive frontend-health monitoring once, unless disabled via the
3092
+ * `overlayHealth` config. Guarded so it never throws into the client.
3093
+ */
3094
+ private startOverlayHealthIfEnabled;
3085
3095
  /**
3086
3096
  * Whether the in-game WebSocket is connected.
3087
3097
  */
package/dist/index.js CHANGED
@@ -1389,10 +1389,436 @@ function createIngameTimerUtils(getGameTime) {
1389
1389
  };
1390
1390
  }
1391
1391
 
1392
+ // ../shared-overlay-health/src/transport.ts
1393
+ function resolveEndpoint(opts) {
1394
+ if (opts.endpoint) return opts.endpoint;
1395
+ const loc = typeof location !== "undefined" ? location : void 0;
1396
+ const https = opts.useHttps ?? loc?.protocol === "https:";
1397
+ const host = opts.host ?? loc?.hostname ?? "localhost";
1398
+ const port = opts.port ?? loc?.port ?? 58869;
1399
+ const portPart = port ? `:${port}` : "";
1400
+ return `${https ? "https" : "http"}://${host}${portPart}/api/overlay/health`;
1401
+ }
1402
+ function sendReport(endpoint, report) {
1403
+ let body;
1404
+ try {
1405
+ body = JSON.stringify(report);
1406
+ } catch {
1407
+ return false;
1408
+ }
1409
+ try {
1410
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") {
1411
+ const blob = new Blob([body], { type: "text/plain;charset=UTF-8" });
1412
+ if (navigator.sendBeacon(endpoint, blob)) return true;
1413
+ }
1414
+ } catch {
1415
+ }
1416
+ try {
1417
+ if (typeof fetch === "function") {
1418
+ void fetch(endpoint, {
1419
+ method: "POST",
1420
+ body,
1421
+ headers: { "Content-Type": "text/plain;charset=UTF-8" },
1422
+ keepalive: true,
1423
+ mode: "cors",
1424
+ credentials: "omit"
1425
+ }).catch(() => {
1426
+ });
1427
+ return true;
1428
+ }
1429
+ } catch {
1430
+ }
1431
+ return false;
1432
+ }
1433
+
1434
+ // ../shared-overlay-health/src/types.ts
1435
+ var DEFAULT_THRESHOLDS = {
1436
+ maxDecodedBytes: 24 * 1024 * 1024,
1437
+ // ~2500×2500 px
1438
+ maxImageDimension: 4096,
1439
+ maxOversampleRatio: 4,
1440
+ oversampleMinArea: 1e6,
1441
+ // ~1 MP
1442
+ maxAssetBytes: 5 * 1024 * 1024,
1443
+ // 5 MB on the wire
1444
+ minFps: 30,
1445
+ maxP95FrameMs: 50,
1446
+ longTaskMs: 200,
1447
+ maxJsHeapMb: 768,
1448
+ maxDomNodes: 5e3
1449
+ };
1450
+
1451
+ // ../shared-overlay-health/src/detector.ts
1452
+ var FRAME_WINDOW_MS = 3e3;
1453
+ var MAX_ISSUES = 50;
1454
+ var TRANSIENT_TTL_MS = 2e4;
1455
+ var TRANSIENT_KINDS = /* @__PURE__ */ new Set([
1456
+ "low-fps",
1457
+ "long-task",
1458
+ "high-memory",
1459
+ "dom-bloat"
1460
+ ]);
1461
+ function now() {
1462
+ return Date.now();
1463
+ }
1464
+ function randomId() {
1465
+ return Math.random().toString(36).slice(2) + Math.random().toString(36).slice(2);
1466
+ }
1467
+ function describeElement(el) {
1468
+ const id = el.id ? `#${el.id}` : "";
1469
+ const cls = typeof el.className === "string" && el.className ? `.${el.className.trim().split(/\s+/).slice(0, 2).join(".")}` : "";
1470
+ return `${el.tagName.toLowerCase()}${id}${cls}`;
1471
+ }
1472
+ var OverlayHealthMonitor = class {
1473
+ constructor(opts) {
1474
+ this.sessionId = randomId();
1475
+ this.issues = /* @__PURE__ */ new Map();
1476
+ /** Recent frame timestamps (epoch ms) within FRAME_WINDOW_MS. */
1477
+ this.frameTimes = [];
1478
+ this.lastFrameAt = 0;
1479
+ this.longTaskTimes = [];
1480
+ this.rafId = null;
1481
+ this.reportTimer = null;
1482
+ this.immediateTimer = null;
1483
+ this.perfObserver = null;
1484
+ this.stopped = false;
1485
+ // ── Detection ──────────────────────────────────────────────────────────
1486
+ this.onError = (e) => {
1487
+ try {
1488
+ const target = e.target;
1489
+ if (target instanceof HTMLImageElement) {
1490
+ const url = target.currentSrc || target.src || target.getAttribute("src") || "(unknown)";
1491
+ this.record({
1492
+ kind: "broken-image",
1493
+ severity: "error",
1494
+ key: url,
1495
+ message: "Image failed to load",
1496
+ url,
1497
+ element: describeElement(target)
1498
+ });
1499
+ return;
1500
+ }
1501
+ const errEvent = e;
1502
+ if (!(target instanceof Element) && errEvent.message) {
1503
+ const where = errEvent.filename ? `${errEvent.filename}:${errEvent.lineno ?? 0}` : "";
1504
+ this.record({
1505
+ kind: "js-error",
1506
+ severity: "error",
1507
+ key: `${errEvent.message}|${where}`,
1508
+ message: errEvent.message,
1509
+ detail: where ? { location: where } : void 0
1510
+ });
1511
+ }
1512
+ } catch {
1513
+ }
1514
+ };
1515
+ this.onLoad = (e) => {
1516
+ try {
1517
+ const target = e.target;
1518
+ if (target instanceof HTMLImageElement) {
1519
+ this.inspectImage(target);
1520
+ }
1521
+ } catch {
1522
+ }
1523
+ };
1524
+ this.onRejection = (e) => {
1525
+ try {
1526
+ const reason = e.reason instanceof Error ? e.reason.message : typeof e.reason === "string" ? e.reason : "Unhandled promise rejection";
1527
+ this.record({
1528
+ kind: "js-error",
1529
+ severity: "error",
1530
+ key: `rejection|${reason}`,
1531
+ message: `Unhandled rejection: ${reason}`
1532
+ });
1533
+ } catch {
1534
+ }
1535
+ };
1536
+ this.onVisibilityChange = () => {
1537
+ if (document.visibilityState === "hidden") this.flush();
1538
+ };
1539
+ this.onPageHide = () => {
1540
+ this.flush();
1541
+ };
1542
+ this.options = {
1543
+ source: opts.source,
1544
+ enabled: opts.enabled ?? true,
1545
+ reportIntervalMs: opts.reportIntervalMs ?? 5e3,
1546
+ thresholds: { ...DEFAULT_THRESHOLDS, ...opts.thresholds ?? {} }
1547
+ };
1548
+ this.endpoint = resolveEndpoint(opts);
1549
+ }
1550
+ start() {
1551
+ if (this.stopped || !this.options.enabled) return;
1552
+ if (typeof window === "undefined") return;
1553
+ window.addEventListener("error", this.onError, true);
1554
+ window.addEventListener("load", this.onLoad, true);
1555
+ window.addEventListener("unhandledrejection", this.onRejection);
1556
+ document.addEventListener("visibilitychange", this.onVisibilityChange);
1557
+ window.addEventListener("pagehide", this.onPageHide);
1558
+ this.startFrameSampling();
1559
+ this.startPerfObserver();
1560
+ this.reportTimer = setInterval(() => this.flush(), this.options.reportIntervalMs);
1561
+ }
1562
+ flush() {
1563
+ if (this.stopped) return;
1564
+ try {
1565
+ const report = this.buildReport();
1566
+ sendReport(this.endpoint, report);
1567
+ } catch {
1568
+ }
1569
+ }
1570
+ stop() {
1571
+ if (this.stopped) return;
1572
+ this.stopped = true;
1573
+ window.removeEventListener("error", this.onError, true);
1574
+ window.removeEventListener("load", this.onLoad, true);
1575
+ window.removeEventListener("unhandledrejection", this.onRejection);
1576
+ document.removeEventListener("visibilitychange", this.onVisibilityChange);
1577
+ window.removeEventListener("pagehide", this.onPageHide);
1578
+ if (this.rafId !== null) cancelAnimationFrame(this.rafId);
1579
+ if (this.reportTimer !== null) clearInterval(this.reportTimer);
1580
+ if (this.immediateTimer !== null) clearTimeout(this.immediateTimer);
1581
+ this.perfObserver?.disconnect();
1582
+ }
1583
+ inspectImage(img) {
1584
+ const nW = img.naturalWidth;
1585
+ const nH = img.naturalHeight;
1586
+ if (!nW || !nH) return;
1587
+ const naturalArea = nW * nH;
1588
+ const decodedBytes = naturalArea * 4;
1589
+ const displayedArea = Math.max(1, img.clientWidth * img.clientHeight);
1590
+ const oversampleRatio = naturalArea / displayedArea;
1591
+ const t = this.options.thresholds;
1592
+ const url = img.currentSrc || img.src;
1593
+ const tooBig = decodedBytes > t.maxDecodedBytes || nW > t.maxImageDimension || nH > t.maxImageDimension;
1594
+ const oversampled = naturalArea > t.oversampleMinArea && oversampleRatio > t.maxOversampleRatio;
1595
+ if (tooBig || oversampled) {
1596
+ this.record({
1597
+ kind: "oversized-image",
1598
+ severity: "warning",
1599
+ key: url,
1600
+ message: tooBig ? `Very large image (${nW}\xD7${nH})` : `Image is much larger than displayed (${nW}\xD7${nH})`,
1601
+ url,
1602
+ element: describeElement(img),
1603
+ detail: {
1604
+ naturalWidth: nW,
1605
+ naturalHeight: nH,
1606
+ displayedWidth: img.clientWidth,
1607
+ displayedHeight: img.clientHeight,
1608
+ approxDecodedMb: Math.round(decodedBytes / (1024 * 1024) * 10) / 10,
1609
+ oversampleRatio: Math.round(oversampleRatio * 10) / 10
1610
+ }
1611
+ });
1612
+ }
1613
+ }
1614
+ startPerfObserver() {
1615
+ if (typeof PerformanceObserver === "undefined") return;
1616
+ try {
1617
+ this.perfObserver = new PerformanceObserver((list) => {
1618
+ for (const entry of list.getEntries()) {
1619
+ if (entry.entryType === "longtask") {
1620
+ this.longTaskTimes.push(now());
1621
+ if (entry.duration >= this.options.thresholds.longTaskMs) {
1622
+ this.record({
1623
+ kind: "long-task",
1624
+ severity: "warning",
1625
+ key: "long-task",
1626
+ message: "Main thread blocked by a long task",
1627
+ detail: { lastDurationMs: Math.round(entry.duration) }
1628
+ });
1629
+ }
1630
+ } else if (entry.entryType === "resource") {
1631
+ this.inspectResource(entry);
1632
+ }
1633
+ }
1634
+ });
1635
+ this.tryObserve({ type: "longtask", buffered: true });
1636
+ this.tryObserve({ type: "resource", buffered: true });
1637
+ } catch {
1638
+ }
1639
+ }
1640
+ tryObserve(init) {
1641
+ try {
1642
+ this.perfObserver?.observe(init);
1643
+ } catch {
1644
+ }
1645
+ }
1646
+ inspectResource(entry) {
1647
+ if (entry.initiatorType !== "img" && entry.initiatorType !== "css") return;
1648
+ const bytes = entry.encodedBodySize || entry.transferSize || 0;
1649
+ if (bytes > this.options.thresholds.maxAssetBytes) {
1650
+ this.record({
1651
+ kind: "large-asset",
1652
+ severity: "warning",
1653
+ key: entry.name,
1654
+ message: `Large asset download (${Math.round(bytes / (1024 * 1024) * 10) / 10} MB)`,
1655
+ url: entry.name,
1656
+ detail: {
1657
+ encodedBytes: bytes,
1658
+ decodedBytes: entry.decodedBodySize || 0,
1659
+ initiator: entry.initiatorType
1660
+ }
1661
+ });
1662
+ }
1663
+ }
1664
+ startFrameSampling() {
1665
+ if (typeof requestAnimationFrame === "undefined") return;
1666
+ this.lastFrameAt = now();
1667
+ const tick = () => {
1668
+ if (this.stopped) return;
1669
+ const t = now();
1670
+ this.frameTimes.push(t);
1671
+ const cutoff = t - FRAME_WINDOW_MS;
1672
+ while (this.frameTimes.length && this.frameTimes[0] < cutoff) this.frameTimes.shift();
1673
+ this.lastFrameAt = t;
1674
+ this.rafId = requestAnimationFrame(tick);
1675
+ };
1676
+ this.rafId = requestAnimationFrame(tick);
1677
+ }
1678
+ // ── Metrics + reporting ────────────────────────────────────────────────
1679
+ computeMetrics() {
1680
+ const frames = this.frameTimes;
1681
+ const deltas = [];
1682
+ for (let i = 1; i < frames.length; i++) deltas.push(frames[i] - frames[i - 1]);
1683
+ const spanMs = frames.length > 1 ? frames[frames.length - 1] - frames[0] : 0;
1684
+ const fps = spanMs > 0 ? Math.round((frames.length - 1) / spanMs * 1e3) : 0;
1685
+ let p95 = 0;
1686
+ let longest = 0;
1687
+ if (deltas.length) {
1688
+ const sorted = [...deltas].sort((a, b) => a - b);
1689
+ p95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))];
1690
+ longest = sorted[sorted.length - 1];
1691
+ }
1692
+ const cutoff = now() - FRAME_WINDOW_MS;
1693
+ this.longTaskTimes = this.longTaskTimes.filter((tt) => tt >= cutoff);
1694
+ const metrics = {
1695
+ fps,
1696
+ p95FrameMs: Math.round(p95),
1697
+ longestFrameMs: Math.round(longest),
1698
+ longTasksInWindow: this.longTaskTimes.length,
1699
+ domNodes: document.getElementsByTagName("*").length
1700
+ };
1701
+ const mem = performance.memory;
1702
+ if (mem) {
1703
+ metrics.jsHeapUsedMb = Math.round(mem.usedJSHeapSize / (1024 * 1024));
1704
+ metrics.jsHeapLimitMb = Math.round(mem.jsHeapSizeLimit / (1024 * 1024));
1705
+ }
1706
+ return metrics;
1707
+ }
1708
+ /** Evaluate window-derived issues (low fps, high memory, dom bloat). */
1709
+ evaluateMetricIssues(metrics) {
1710
+ const t = this.options.thresholds;
1711
+ if (this.frameTimes.length > 30) {
1712
+ if (metrics.fps < t.minFps || metrics.p95FrameMs > t.maxP95FrameMs) {
1713
+ this.record({
1714
+ kind: "low-fps",
1715
+ severity: "warning",
1716
+ key: "low-fps",
1717
+ message: `Low frame rate (${metrics.fps} fps)`,
1718
+ detail: { fps: metrics.fps, p95FrameMs: metrics.p95FrameMs }
1719
+ });
1720
+ }
1721
+ }
1722
+ if (metrics.jsHeapUsedMb !== void 0 && metrics.jsHeapUsedMb > t.maxJsHeapMb) {
1723
+ this.record({
1724
+ kind: "high-memory",
1725
+ severity: "warning",
1726
+ key: "high-memory",
1727
+ message: `High memory use (${metrics.jsHeapUsedMb} MB)`,
1728
+ detail: { jsHeapUsedMb: metrics.jsHeapUsedMb }
1729
+ });
1730
+ }
1731
+ if (metrics.domNodes > t.maxDomNodes) {
1732
+ this.record({
1733
+ kind: "dom-bloat",
1734
+ severity: metrics.domNodes > t.maxDomNodes * 2 ? "warning" : "info",
1735
+ key: "dom-bloat",
1736
+ message: `Large DOM (${metrics.domNodes} elements)`,
1737
+ detail: { domNodes: metrics.domNodes }
1738
+ });
1739
+ }
1740
+ }
1741
+ buildReport() {
1742
+ const metrics = this.computeMetrics();
1743
+ this.evaluateMetricIssues(metrics);
1744
+ this.pruneTransientIssues();
1745
+ return {
1746
+ source: this.options.source,
1747
+ sessionId: this.sessionId,
1748
+ url: typeof location !== "undefined" ? location.href : "",
1749
+ userAgent: typeof navigator !== "undefined" ? navigator.userAgent : "",
1750
+ timestamp: now(),
1751
+ metrics,
1752
+ issues: [...this.issues.values()]
1753
+ };
1754
+ }
1755
+ record(input) {
1756
+ const mapKey = `${input.kind}:${input.key}`;
1757
+ const ts = now();
1758
+ const existing = this.issues.get(mapKey);
1759
+ if (existing) {
1760
+ existing.count++;
1761
+ existing.lastSeen = ts;
1762
+ existing.message = input.message;
1763
+ if (input.detail) existing.detail = input.detail;
1764
+ return;
1765
+ }
1766
+ if (this.issues.size >= MAX_ISSUES) this.evictOldest();
1767
+ this.issues.set(mapKey, {
1768
+ kind: input.kind,
1769
+ severity: input.severity,
1770
+ key: input.key,
1771
+ message: input.message,
1772
+ url: input.url,
1773
+ element: input.element,
1774
+ detail: input.detail,
1775
+ count: 1,
1776
+ firstSeen: ts,
1777
+ lastSeen: ts
1778
+ });
1779
+ if (input.severity === "error") this.scheduleImmediate();
1780
+ }
1781
+ scheduleImmediate() {
1782
+ if (this.immediateTimer !== null) return;
1783
+ this.immediateTimer = setTimeout(() => {
1784
+ this.immediateTimer = null;
1785
+ this.flush();
1786
+ }, 750);
1787
+ }
1788
+ pruneTransientIssues() {
1789
+ const cutoff = now() - TRANSIENT_TTL_MS;
1790
+ for (const [k, issue] of this.issues) {
1791
+ if (TRANSIENT_KINDS.has(issue.kind) && issue.lastSeen < cutoff) {
1792
+ this.issues.delete(k);
1793
+ }
1794
+ }
1795
+ }
1796
+ evictOldest() {
1797
+ let oldestKey = null;
1798
+ let oldest = Infinity;
1799
+ for (const [k, issue] of this.issues) {
1800
+ if (issue.lastSeen < oldest) {
1801
+ oldest = issue.lastSeen;
1802
+ oldestKey = k;
1803
+ }
1804
+ }
1805
+ if (oldestKey) this.issues.delete(oldestKey);
1806
+ }
1807
+ };
1808
+
1809
+ // ../shared-overlay-health/src/index.ts
1810
+ function startOverlayHealth(options) {
1811
+ const monitor = new OverlayHealthMonitor(options);
1812
+ monitor.start();
1813
+ return monitor;
1814
+ }
1815
+
1392
1816
  // src/LeagueBroadcastClient.ts
1393
1817
  var _clockInterval = null;
1394
1818
  var LeagueBroadcastClient = class {
1395
1819
  constructor(config) {
1820
+ // -- Frontend-health monitoring ---------------------------------------------
1821
+ this._overlayHealth = null;
1396
1822
  this.gameState = 0 /* OutOfGame */;
1397
1823
  this._isTestingEnvironment = false;
1398
1824
  this.champSelectWasActive = false;
@@ -1424,7 +1850,8 @@ var LeagueBroadcastClient = class {
1424
1850
  apiRoute: config.apiRoute ?? "/api",
1425
1851
  cacheRoute: config.cacheRoute ?? "/cache",
1426
1852
  useHttps: config.useHttps ?? false,
1427
- autoConnect: config.autoConnect ?? true
1853
+ autoConnect: config.autoConnect ?? true,
1854
+ overlayHealth: config.overlayHealth ?? true
1428
1855
  };
1429
1856
  const httpProtocol = this.config.useHttps ? "https" : "http";
1430
1857
  const apiBaseUrl = `${httpProtocol}://${this.config.host}:${this.config.port}${this.config.apiRoute}`;
@@ -1473,6 +1900,7 @@ var LeagueBroadcastClient = class {
1473
1900
  * promise rejects, but the other connection may still succeed.
1474
1901
  */
1475
1902
  async connect() {
1903
+ this.startOverlayHealthIfEnabled();
1476
1904
  const protocol = this.config.useHttps ? "wss" : "ws";
1477
1905
  const base = `${protocol}://${this.config.host}:${this.config.port}`;
1478
1906
  const ingameUrl = `${base}${this.config.ingameWsRoute}`;
@@ -1499,6 +1927,28 @@ var LeagueBroadcastClient = class {
1499
1927
  disconnect() {
1500
1928
  this.ingameWs.disconnect();
1501
1929
  this.preGameWs.disconnect();
1930
+ this._overlayHealth?.stop();
1931
+ this._overlayHealth = null;
1932
+ }
1933
+ /**
1934
+ * Starts passive frontend-health monitoring once, unless disabled via the
1935
+ * `overlayHealth` config. Guarded so it never throws into the client.
1936
+ */
1937
+ startOverlayHealthIfEnabled() {
1938
+ if (this._overlayHealth) return;
1939
+ const setting = this.config.overlayHealth;
1940
+ if (setting === false) return;
1941
+ const opts = typeof setting === "object" ? setting : {};
1942
+ if (opts.enabled === false) return;
1943
+ try {
1944
+ this._overlayHealth = startOverlayHealth({
1945
+ source: { kind: "sdk", name: opts.name },
1946
+ host: this.config.host,
1947
+ port: this.config.port,
1948
+ useHttps: this.config.useHttps
1949
+ });
1950
+ } catch {
1951
+ }
1502
1952
  }
1503
1953
  /**
1504
1954
  * Whether the in-game WebSocket is connected.
@@ -1819,11 +2269,6 @@ var LeagueBroadcastClient = class {
1819
2269
  (event) => this.ingameEventHandlers.onKillFeedEvent.forEach((h) => h(event))
1820
2270
  );
1821
2271
  }
1822
- if (events.announcements) {
1823
- events.announcements.forEach(
1824
- (event) => this.ingameEventHandlers.onAnnouncementEvent.forEach((h) => h(event))
1825
- );
1826
- }
1827
2272
  if (events.smiteReaction !== void 0) {
1828
2273
  this.ingameEventHandlers.onSmiteReactionEvent.forEach(
1829
2274
  (h) => h(events.smiteReaction)