@windstream/react-shared-components 0.2.50 → 0.2.51

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.
@@ -1,78 +1,114 @@
1
1
  import React from "react";
2
- import { YoutubeEmbed } from "./youtube-embed";
2
+ import { getYoutubeEmbedId, YoutubeEmbed } from "./youtube-embed";
3
3
 
4
- import { render, screen } from "@testing-library/react";
4
+ import { render, screen, waitFor } from "@testing-library/react";
5
5
 
6
6
  jest.mock("@shared/utils", () => ({
7
7
  cx: (...args: any[]) => args.filter(Boolean).join(" "),
8
8
  }));
9
9
 
10
- describe("YoutubeEmbed", () => {
11
- it("renders iframe with extracted video id from watch URL", () => {
12
- render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
13
- const iframe = screen.getByTitle("Embedded youtube");
14
- expect(iframe.getAttribute("src")).toBe(
15
- "https://www.youtube.com/embed/dQw4w9WgXcQ"
16
- );
10
+ const mockPlayVideo = jest.fn();
11
+ let capturedEvents: any;
12
+ let capturedVideoId: string | undefined;
13
+ let capturedPlayerVars: any;
14
+
15
+ jest.mock("./youtube-iframe-api-loader", () => ({
16
+ loadYoutubeIframeApi: () =>
17
+ Promise.resolve({
18
+ PlayerState: { PLAYING: 1, PAUSED: 2, ENDED: 0 },
19
+ Player: jest.fn().mockImplementation((_el, options) => {
20
+ capturedEvents = options.events;
21
+ capturedVideoId = options.videoId;
22
+ capturedPlayerVars = options.playerVars;
23
+ return { playVideo: mockPlayVideo, destroy: jest.fn() };
24
+ }),
25
+ }),
26
+ }));
27
+
28
+ describe("getYoutubeEmbedId", () => {
29
+ it("extracts id from watch URL", () => {
30
+ expect(
31
+ getYoutubeEmbedId("https://www.youtube.com/watch?v=dQw4w9WgXcQ")
32
+ ).toBe("dQw4w9WgXcQ");
17
33
  });
18
34
 
19
35
  it("extracts id from youtu.be short URL", () => {
20
- render(<YoutubeEmbed link="https://youtu.be/dQw4w9WgXcQ" />);
21
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toContain(
22
- "/embed/dQw4w9WgXcQ"
36
+ expect(getYoutubeEmbedId("https://youtu.be/dQw4w9WgXcQ")).toBe(
37
+ "dQw4w9WgXcQ"
23
38
  );
24
39
  });
25
40
 
26
41
  it("extracts id from embed URL", () => {
27
- render(<YoutubeEmbed link="https://www.youtube.com/embed/dQw4w9WgXcQ" />);
28
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toContain(
29
- "/embed/dQw4w9WgXcQ"
42
+ expect(getYoutubeEmbedId("https://www.youtube.com/embed/dQw4w9WgXcQ")).toBe(
43
+ "dQw4w9WgXcQ"
30
44
  );
31
45
  });
32
46
 
33
- it("returns empty embedId for invalid URL", () => {
34
- render(<YoutubeEmbed link="https://example.com/notavideo" />);
35
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
36
- "https://www.youtube.com/embed/"
37
- );
47
+ it("returns empty string for invalid URL", () => {
48
+ expect(getYoutubeEmbedId("https://example.com/notavideo")).toBe("");
38
49
  });
39
50
 
40
- it("returns empty embedId when link is empty", () => {
41
- render(<YoutubeEmbed link="" />);
42
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
43
- "https://www.youtube.com/embed/"
44
- );
51
+ it("returns empty string when link is empty or undefined", () => {
52
+ expect(getYoutubeEmbedId("")).toBe("");
53
+ expect(getYoutubeEmbedId(undefined)).toBe("");
45
54
  });
46
55
 
47
- it("returns empty embedId when link is undefined", () => {
48
- render(<YoutubeEmbed link={undefined} />);
49
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
50
- "https://www.youtube.com/embed/"
51
- );
56
+ it("returns empty string when match length is not 11", () => {
57
+ expect(getYoutubeEmbedId("https://www.youtube.com/watch?v=short")).toBe("");
58
+ });
59
+ });
60
+
61
+ describe("YoutubeEmbed", () => {
62
+ beforeEach(() => {
63
+ mockPlayVideo.mockClear();
64
+ capturedEvents = undefined;
65
+ capturedVideoId = undefined;
66
+ capturedPlayerVars = undefined;
52
67
  });
53
68
 
54
- it("appends autoplay param when autoplay is true", () => {
69
+ it("renders a placeholder with an accessible title", () => {
70
+ render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
71
+ expect(screen.getByTitle("Embedded youtube")).toBeInTheDocument();
72
+ });
73
+
74
+ it("constructs the player with the extracted videoId and enablejsapi, autoplay/mute off by default", async () => {
75
+ render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
76
+ await waitFor(() => expect(capturedVideoId).toBeDefined());
77
+ expect(capturedVideoId).toBe("dQw4w9WgXcQ");
78
+ expect(capturedPlayerVars).toEqual({ enablejsapi: 1, autoplay: 0, mute: 0 });
79
+ });
80
+
81
+ it("passes autoplay and mute declaratively via playerVars when autoplay is true", async () => {
55
82
  render(
56
83
  <YoutubeEmbed
57
84
  link="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
58
85
  autoplay={true}
59
86
  />
60
87
  );
61
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
62
- "https://www.youtube.com/embed/dQw4w9WgXcQ?autoplay=1"
63
- );
88
+ await waitFor(() => expect(capturedPlayerVars).toBeDefined());
89
+ expect(capturedPlayerVars).toEqual({ enablejsapi: 1, autoplay: 1, mute: 1 });
64
90
  });
65
91
 
66
- it("does not append autoplay param when autoplay is false", () => {
92
+ it("reports play/pause/ended via onStateChange", async () => {
93
+ const onPlay = jest.fn();
94
+ const onPause = jest.fn();
95
+ const onEnded = jest.fn();
67
96
  render(
68
97
  <YoutubeEmbed
69
98
  link="https://www.youtube.com/watch?v=dQw4w9WgXcQ"
70
- autoplay={false}
99
+ onPlay={onPlay}
100
+ onPause={onPause}
101
+ onEnded={onEnded}
71
102
  />
72
103
  );
73
- expect(
74
- screen.getByTitle("Embedded youtube").getAttribute("src")
75
- ).not.toContain("autoplay");
104
+ await waitFor(() => expect(capturedEvents).toBeDefined());
105
+
106
+ capturedEvents.onStateChange({ data: 1 });
107
+ expect(onPlay).toHaveBeenCalled();
108
+ capturedEvents.onStateChange({ data: 2 });
109
+ expect(onPause).toHaveBeenCalled();
110
+ capturedEvents.onStateChange({ data: 0 });
111
+ expect(onEnded).toHaveBeenCalled();
76
112
  });
77
113
 
78
114
  it("applies containerClassName", () => {
@@ -85,20 +121,11 @@ describe("YoutubeEmbed", () => {
85
121
  expect(container.firstElementChild?.className).toContain("extra");
86
122
  });
87
123
 
88
- it("has correct iframe attributes", () => {
89
- render(<YoutubeEmbed link="https://www.youtube.com/watch?v=dQw4w9WgXcQ" />);
90
- const iframe = screen.getByTitle("Embedded youtube");
91
- expect(iframe).toHaveAttribute(
92
- "allow",
93
- "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
94
- );
95
- expect(iframe).toHaveAttribute("allowFullScreen");
96
- });
97
-
98
- it("returns empty embedId when match length is not 11", () => {
99
- render(<YoutubeEmbed link="https://www.youtube.com/watch?v=short" />);
100
- expect(screen.getByTitle("Embedded youtube").getAttribute("src")).toBe(
101
- "https://www.youtube.com/embed/"
102
- );
124
+ it("does not attempt to load the API when embedId is empty", async () => {
125
+ render(<YoutubeEmbed link="https://example.com/notavideo" />);
126
+ await new Promise(resolve => {
127
+ setTimeout(resolve, 0);
128
+ });
129
+ expect(capturedVideoId).toBeUndefined();
103
130
  });
104
131
  });
@@ -1,6 +1,8 @@
1
- import React, { useMemo } from "react";
1
+ import React, { useEffect, useMemo, useRef } from "react";
2
2
  import { VideoEmbedProps } from "./types";
3
+ import { loadYoutubeIframeApi } from "./youtube-iframe-api-loader";
3
4
 
5
+ import { useFullscreenToggle } from "@shared/hooks/use-fullscreen-toggle";
4
6
  import { cx } from "@shared/utils";
5
7
 
6
8
  // import styles from "./styles.module.scss";
@@ -10,22 +12,103 @@ export type Props = {
10
12
  autoplay?: boolean; // Add the autoplay prop
11
13
  } & VideoEmbedProps;
12
14
 
15
+ export const getYoutubeEmbedId = (link?: string): string => {
16
+ if (!link) return "";
17
+
18
+ const href = link.toString();
19
+ const regExp =
20
+ /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
21
+ const match = href.match(regExp);
22
+
23
+ return match && match[7].length === 11 ? match[7] : "";
24
+ };
25
+
13
26
  export const YoutubeEmbed: React.FC<Props> = props => {
14
- const { link, containerClassName, autoplay = false } = props;
27
+ const {
28
+ link,
29
+ containerClassName,
30
+ autoplay = false,
31
+ onPlay,
32
+ onPause,
33
+ onTimeUpdate,
34
+ onEnded,
35
+ onFullscreenToggle,
36
+ } = props;
37
+
38
+ const embedId = useMemo(() => getYoutubeEmbedId(link), [link]);
39
+ const containerRef = useRef<HTMLDivElement>(null);
40
+ const iframeRef = useRef<HTMLIFrameElement | null>(null);
41
+ const progressInterval = useRef<ReturnType<typeof setInterval>>();
42
+
43
+ useFullscreenToggle(iframeRef, onFullscreenToggle);
15
44
 
16
- const embedId = useMemo(() => {
17
- if (!link) return "";
45
+ useEffect(() => {
46
+ if (!embedId || !containerRef.current) return undefined;
47
+ let player: any;
48
+ let cancelled = false;
18
49
 
19
- const href = link.toString();
20
- const regExp =
21
- /^.*((youtu.be\/)|(v\/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#&?]*).*/;
22
- const match = href.match(regExp);
50
+ // The YT.Player-managed iframe is mounted into a plain DOM node created
51
+ // here, outside of React's own render tree/reconciliation - React only
52
+ // ever renders an empty container div for this component, so it never
53
+ // diffs/touches whatever the API puts inside it. Letting React manage
54
+ // the same node the API mutates is a well-known source of onReady/
55
+ // onStateChange silently never firing (a reconciliation pass can revert
56
+ // the API's DOM changes without going through this component's own
57
+ // effect cleanup).
58
+ const mountNode = document.createElement("div");
59
+ mountNode.style.width = "100%";
60
+ mountNode.style.height = "100%";
61
+ containerRef.current.appendChild(mountNode);
23
62
 
24
- return match && match[7].length === 11 ? match[7] : "";
25
- }, [link]);
63
+ loadYoutubeIframeApi().then(YT => {
64
+ if (cancelled) return;
65
+ player = new YT.Player(mountNode, {
66
+ videoId: embedId,
67
+ playerVars: {
68
+ enablejsapi: 1,
69
+ autoplay: autoplay ? 1 : 0,
70
+ mute: autoplay ? 1 : 0,
71
+ },
72
+ events: {
73
+ onReady: () => {
74
+ const iframe = player.getIframe?.();
75
+ if (iframe) {
76
+ iframe.className = "absolute left-0 top-0 h-full w-full";
77
+ iframeRef.current = iframe;
78
+ }
79
+ },
80
+ onStateChange: (event: any) => {
81
+ const State = YT.PlayerState;
82
+ if (event.data === State.PLAYING) {
83
+ onPlay?.();
84
+ clearInterval(progressInterval.current);
85
+ progressInterval.current = setInterval(() => {
86
+ const duration = player?.getDuration?.() ?? 0;
87
+ const position = player?.getCurrentTime?.() ?? 0;
88
+ onTimeUpdate?.(position, duration);
89
+ }, 1000);
90
+ } else if (event.data === State.PAUSED) {
91
+ onPause?.();
92
+ clearInterval(progressInterval.current);
93
+ } else if (event.data === State.ENDED) {
94
+ onEnded?.();
95
+ clearInterval(progressInterval.current);
96
+ }
97
+ },
98
+ },
99
+ });
100
+ }).catch(() => {
101
+ // ignore YouTube API load failures; component will remain empty
102
+ });
26
103
 
27
- // Add autoplay parameter to the URL if autoplay is true
28
- const autoplayParam = autoplay ? "?autoplay=1" : "";
104
+ return () => {
105
+ cancelled = true;
106
+ clearInterval(progressInterval.current);
107
+ player?.destroy?.();
108
+ mountNode.remove();
109
+ };
110
+ // eslint-disable-next-line react-hooks/exhaustive-deps
111
+ }, [embedId]);
29
112
 
30
113
  return (
31
114
  <div
@@ -34,11 +117,9 @@ export const YoutubeEmbed: React.FC<Props> = props => {
34
117
  containerClassName
35
118
  )}
36
119
  >
37
- <iframe
120
+ <div
121
+ ref={containerRef}
38
122
  className="absolute left-0 top-0 h-full w-full"
39
- src={`https://www.youtube.com/embed/${embedId}${autoplayParam}`}
40
- allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
41
- allowFullScreen={true}
42
123
  title="Embedded youtube"
43
124
  />
44
125
  </div>
@@ -0,0 +1,45 @@
1
+ let apiPromise: Promise<typeof window.YT> | null = null;
2
+
3
+ export function loadYoutubeIframeApi(): Promise<typeof window.YT> {
4
+ if (typeof window === "undefined") {
5
+ return Promise.reject(new Error("YouTube IFrame API requires a browser"));
6
+ }
7
+
8
+ if (window.YT?.Player) {
9
+ return Promise.resolve(window.YT);
10
+ }
11
+
12
+ if (apiPromise) {
13
+ return apiPromise;
14
+ }
15
+
16
+ apiPromise = new Promise((resolve, reject) => {
17
+ const previousCallback = window.onYouTubeIframeAPIReady;
18
+ window.onYouTubeIframeAPIReady = () => {
19
+ previousCallback?.();
20
+ resolve(window.YT);
21
+ };
22
+
23
+ let script = document.querySelector<HTMLScriptElement>(
24
+ 'script[src*="youtube.com/iframe_api"]'
25
+ );
26
+ if (!script) {
27
+ script = document.createElement("script");
28
+ script.src = "https://www.youtube.com/iframe_api";
29
+ script.onerror = () => {
30
+ apiPromise = null;
31
+ reject(new Error("Failed to load YouTube IFrame API"));
32
+ };
33
+ document.head.appendChild(script);
34
+ }
35
+ });
36
+
37
+ return apiPromise;
38
+ }
39
+
40
+ declare global {
41
+ interface Window {
42
+ YT: any;
43
+ onYouTubeIframeAPIReady?: () => void;
44
+ }
45
+ }
@@ -177,14 +177,14 @@ describe("ImagePromoBar", () => {
177
177
  expect(container.querySelector(".max-w-120")).not.toBeInTheDocument();
178
178
  });
179
179
 
180
- it("applies lg:flex-row-reverse when mediaPosition is true", () => {
180
+ it("applies xl:flex-row-reverse when mediaPosition is true", () => {
181
181
  const { container } = render(
182
182
  <ImagePromoBar {...defaultProps} mediaPosition={true} />
183
183
  );
184
184
  expect(container.innerHTML).toContain("xl:flex-row-reverse");
185
185
  });
186
186
 
187
- it("applies lg:flex-row when mediaPosition is false", () => {
187
+ it("applies xl:flex-row when mediaPosition is false", () => {
188
188
  const { container } = render(
189
189
  <ImagePromoBar {...defaultProps} mediaPosition={false} />
190
190
  );
@@ -34,6 +34,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
34
34
  isAboveTheFold = false,
35
35
  onModalButtonClick,
36
36
  onClick,
37
+ onVideoEvent,
37
38
  renderCheckPlans,
38
39
  enableAnimation = false,
39
40
  }) => {
@@ -200,6 +201,7 @@ export const ImagePromoBar: React.FC<ImagePromoBarProps> = ({
200
201
  imageHeight={imageHeight}
201
202
  useOriginalImageAspectRatio={useOriginalImageAspectRatio}
202
203
  isAboveTheFold={isAboveTheFold}
204
+ onVideoEvent={onVideoEvent}
203
205
  />
204
206
  </aside>
205
207
  </div>
@@ -1,6 +1,9 @@
1
1
  import React from "react";
2
2
 
3
- import { VideoLinkProps } from "@shared/components/video-link";
3
+ import {
4
+ VideoAnalyticsHandler,
5
+ VideoLinkProps,
6
+ } from "@shared/components/video-link";
4
7
  import { CheckPlansProps } from "@shared/types/micro-components";
5
8
 
6
9
  export type ImagePromoBarProps = {
@@ -27,6 +30,7 @@ export type ImagePromoBarProps = {
27
30
  maxWidth?: boolean;
28
31
  color: "light" | "dark";
29
32
  enableAnimation?: boolean;
33
+ onVideoEvent?: VideoAnalyticsHandler;
30
34
  onModalButtonClick?: (id?: string) => void;
31
35
  onClick?: (event: React.MouseEvent, source: "primary" | "secondary") => void;
32
36
  renderCheckPlans?: (overrides?: CheckPlansProps) => React.ReactNode;
@@ -0,0 +1,23 @@
1
+ import { useEffect, type RefObject } from "react";
2
+
3
+ export function useFullscreenToggle(
4
+ ref: RefObject<Element>,
5
+ onToggle?: (isFullscreen: boolean) => void
6
+ ) {
7
+ useEffect(() => {
8
+ if (!onToggle) return;
9
+
10
+ const handleChange = () => {
11
+ const fullscreenElement =
12
+ document.fullscreenElement || (document as any).webkitFullscreenElement;
13
+ onToggle(fullscreenElement === ref.current);
14
+ };
15
+
16
+ document.addEventListener("fullscreenchange", handleChange);
17
+ document.addEventListener("webkitfullscreenchange", handleChange);
18
+ return () => {
19
+ document.removeEventListener("fullscreenchange", handleChange);
20
+ document.removeEventListener("webkitfullscreenchange", handleChange);
21
+ };
22
+ }, [onToggle, ref]);
23
+ }
@@ -0,0 +1,126 @@
1
+ import { useRef } from "react";
2
+
3
+ export type VideoProvider = "youtube" | "vimeo" | "contentful";
4
+
5
+ export type VideoAnalyticsEventName =
6
+ | "played"
7
+ | "paused"
8
+ | "progress"
9
+ | "completed"
10
+ | "fullscreen_toggled"
11
+ | "closed";
12
+
13
+ export type VideoAnalyticsPayload = {
14
+ videoId: string;
15
+ videoTitle?: string;
16
+ provider: VideoProvider;
17
+ position: number;
18
+ duration: number;
19
+ percentComplete: number;
20
+ isFullscreen?: boolean;
21
+ };
22
+
23
+ export type VideoAnalyticsHandler = (
24
+ event: VideoAnalyticsEventName,
25
+ payload: VideoAnalyticsPayload
26
+ ) => void;
27
+
28
+ export type VideoAnalyticsMeta = {
29
+ videoId: string;
30
+ videoTitle?: string;
31
+ provider: VideoProvider;
32
+ };
33
+
34
+ const PROGRESS_MILESTONES = [25, 50, 75];
35
+
36
+ // Centralizes progress-milestone/completion dedup so each provider embed
37
+ // only has to report raw play/pause/timeupdate/ended/fullscreen callbacks -
38
+ // none of them know about Segment or the unified event schema.
39
+ export function useVideoAnalytics(onVideoEvent?: VideoAnalyticsHandler) {
40
+ const firedMilestones = useRef<Set<number>>(new Set());
41
+ const hasCompleted = useRef(false);
42
+ const lastPosition = useRef(0);
43
+ const lastDuration = useRef(0);
44
+
45
+ const emit = (
46
+ meta: VideoAnalyticsMeta,
47
+ event: VideoAnalyticsEventName,
48
+ extra?: Partial<VideoAnalyticsPayload>
49
+ ) => {
50
+ if (!onVideoEvent) return;
51
+ onVideoEvent(event, {
52
+ videoId: meta.videoId,
53
+ videoTitle: meta.videoTitle,
54
+ provider: meta.provider,
55
+ position: lastPosition.current,
56
+ duration: lastDuration.current,
57
+ percentComplete:
58
+ lastDuration.current > 0
59
+ ? Math.min(
60
+ 100,
61
+ Math.round((lastPosition.current / lastDuration.current) * 100)
62
+ )
63
+ : 0,
64
+ ...extra,
65
+ });
66
+ };
67
+
68
+ const reset = () => {
69
+ firedMilestones.current = new Set();
70
+ hasCompleted.current = false;
71
+ lastPosition.current = 0;
72
+ lastDuration.current = 0;
73
+ };
74
+
75
+ const handlePlay = (meta: VideoAnalyticsMeta) => emit(meta, "played");
76
+
77
+ const handlePause = (meta: VideoAnalyticsMeta) => emit(meta, "paused");
78
+
79
+ const handleFullscreenToggle = (
80
+ meta: VideoAnalyticsMeta,
81
+ isFullscreen: boolean
82
+ ) => emit(meta, "fullscreen_toggled", { isFullscreen });
83
+
84
+ const handleClosed = (meta: VideoAnalyticsMeta) => emit(meta, "closed");
85
+
86
+ const handleTimeUpdate = (
87
+ meta: VideoAnalyticsMeta,
88
+ position: number,
89
+ duration: number
90
+ ) => {
91
+ const safePosition = Number.isFinite(position) ? position : 0;
92
+ const safeDuration = Number.isFinite(duration) ? duration : 0;
93
+ lastPosition.current = safePosition;
94
+ lastDuration.current = safeDuration;
95
+ if (safeDuration <= 0) return;
96
+ const percent = Math.min(100, (safePosition / safeDuration) * 100);
97
+ PROGRESS_MILESTONES.forEach(milestone => {
98
+ if (percent >= milestone && !firedMilestones.current.has(milestone)) {
99
+ firedMilestones.current.add(milestone);
100
+ emit(meta, "progress");
101
+ }
102
+ });
103
+
104
+ if (percent >= 99.5 && !hasCompleted.current) {
105
+ hasCompleted.current = true;
106
+ emit(meta, "completed");
107
+ }
108
+ };
109
+
110
+ const handleEnded = (meta: VideoAnalyticsMeta) => {
111
+ if (hasCompleted.current) return;
112
+ hasCompleted.current = true;
113
+ if (lastDuration.current > 0) lastPosition.current = lastDuration.current;
114
+ emit(meta, "completed");
115
+ };
116
+
117
+ return {
118
+ handlePlay,
119
+ handlePause,
120
+ handleFullscreenToggle,
121
+ handleClosed,
122
+ handleTimeUpdate,
123
+ handleEnded,
124
+ reset,
125
+ };
126
+ }
@@ -0,0 +1,11 @@
1
+ declare module "@vimeo/player" {
2
+ export default class Player {
3
+ constructor(
4
+ element: HTMLIFrameElement | HTMLElement | string,
5
+ options?: any
6
+ );
7
+ on(event: string, callback: (data: any) => void): void;
8
+ off(event: string, callback?: (data: any) => void): void;
9
+ destroy(): Promise<void>;
10
+ }
11
+ }