diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index 99a5c2692..071d2fb6b 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -14,6 +14,7 @@ A Vite plugin that builds a deployable Datadog Apps package. Publishing is owned - [Development server authentication](#development-server-authentication) - [Development server requests](#development-server-requests) - [Cloud execution](#cloud-execution) +- [Framed backend function calls](#framed-backend-function-calls) - [Package output](#package-output) - [apps.enable](#appsenable) - [apps.include](#appsinclude) @@ -92,6 +93,15 @@ Cloud execution (`dev:verify`, `/__dd/executeActionViaCloud`) bundles a backend When Datadog rejects a bundle as longer than the `jsFunctionWithActions` `script` input accepts, the dev server answers with a 413 naming the function, its size and Datadog's answer, and warns once per function until the dev server restarts. +## Framed backend function calls + +When the app runs in a frame, a backend function call posts `app-builder:run-query` to the parent window and waits for the `app-builder:run-query:response` with the same `requestId`: + +- the wait times out after 2 minutes; +- the parent can restart it with `{ type: 'app-builder:run-query:keepalive', requestId }`, for example every 30 seconds while it shows a consent dialog: each keepalive restarts the 2-minute wait, it doesn't pause it; +- a call still times out 60 minutes after it was posted, however often it is kept alive, so a parent that never answers can't hold it forever; +- the frame accepts a response or keepalive only from its parent window and, when the call was posted to an exact origin, only from that origin. + ## Package output A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time. diff --git a/packages/plugins/apps/src/backend/client/dev-client/dev-client.test.ts b/packages/plugins/apps/src/backend/client/dev-client/dev-client.test.ts index 6fc993ad0..d4c7c4f30 100644 --- a/packages/plugins/apps/src/backend/client/dev-client/dev-client.test.ts +++ b/packages/plugins/apps/src/backend/client/dev-client/dev-client.test.ts @@ -540,6 +540,31 @@ describe('dev client', () => { expect(remainingListeners).toBe(0); }); + test("Should learn the parent's origin from a keepalive after giving up on pings", async () => { + jest.useFakeTimers(); + const { postMessage, reply, keepAlive, listenerCount } = installFakeWindow({}); + mockFetchResponse({ ok: true, json: async () => BUNDLE }); + await exhaustPings(); + + const first = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + const [firstQuery] = postMessage.mock.calls.at(-1); + expect(firstQuery).toEqual(MESSAGE_WITHOUT_BUNDLE); + keepAlive(firstQuery.requestId); + + const second = postMessageTransport(QUERY_NAME, ARGS); + await jest.advanceTimersByTimeAsync(0); + const [secondQuery] = postMessage.mock.calls.at(-1); + expect(secondQuery).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + expect(postMessage).toHaveBeenCalledWith(secondQuery, DATADOG_PARENT); + reply(firstQuery.requestId, 'first'); + reply(secondQuery.requestId, 'second'); + await expect(first).resolves.toBe('first'); + await expect(second).resolves.toBe('second'); + const remainingListeners = listenerCount(); + expect(remainingListeners).toBe(0); + }); + test('Should use a late answer to an earlier ping', async () => { jest.useFakeTimers(); const { postMessage, reply, pings, pong } = installFakeWindow({}); diff --git a/packages/plugins/apps/src/backend/client/dev-client/dev-client.ts b/packages/plugins/apps/src/backend/client/dev-client/dev-client.ts index 9deeff41c..359268584 100644 --- a/packages/plugins/apps/src/backend/client/dev-client/dev-client.ts +++ b/packages/plugins/apps/src/backend/client/dev-client/dev-client.ts @@ -6,7 +6,13 @@ import { parseSite } from '@dd/core/helpers/site'; -import { DEBUG_BUNDLE_PATH, PING, PONG, RUN_QUERY_RESPONSE } from '../../protocol'; +import { + DEBUG_BUNDLE_PATH, + PING, + PONG, + RUN_QUERY_KEEPALIVE, + RUN_QUERY_RESPONSE, +} from '../../protocol'; import type { DebugBundleResponse, ExecuteActionRequest, @@ -20,10 +26,14 @@ export const BUNDLE_FETCH_TIMEOUT_MS = 60_000; export const PARENT_PING_TIMEOUT_MS = 2_000; // The bridge requires a ping's requestId, but any answer from the parent will do: the frame only needs its origin. +const PARENT_ANSWERS: ReadonlySet = new Set([ + PONG, + RUN_QUERY_RESPONSE, + RUN_QUERY_KEEPALIVE, +]); + function isParentAnswer(data: unknown): boolean { - return ( - isRecord(data) && 'type' in data && (data.type === PONG || data.type === RUN_QUERY_RESPONSE) - ); + return isRecord(data) && 'type' in data && PARENT_ANSWERS.has(data.type); } function isDatadogOrigin(origin: string): boolean { @@ -60,7 +70,7 @@ export const resetDevClient = (): void => { warnedOpaqueParentOrigin = false; }; -// The browser stamps the sender's origin on each message, so a pong or response from the parent reveals it. +// The browser stamps the sender's origin on each message, so any answer from the parent reveals it. function handleParentMessage(event: MessageEvent): void { if (event.source !== window.parent || !isParentAnswer(event.data)) { return; @@ -104,7 +114,7 @@ function pingParent(): Promise { } // Firefox has no ancestorOrigins; App Builder's bridge answers a ping from its frame. -// After MAX_UNANSWERED_PINGS misses, calls stop pinging; a later pong or response still sets the origin. +// After MAX_UNANSWERED_PINGS misses, calls stop pinging; a later parent answer still sets the origin. function needsParentPing(): boolean { return ( !window.location.ancestorOrigins && diff --git a/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.test.ts b/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.test.ts index f4ae7de6a..dd9ba2614 100644 --- a/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.test.ts +++ b/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.test.ts @@ -14,10 +14,14 @@ import { mockFetchResponse, } from '@dd/tests/_jest/helpers/fakeWindow'; -import { DEV_CLIENT_HOOK, RUN_QUERY_RESPONSE } from '../../../protocol'; +import { DEV_CLIENT_HOOK, RUN_QUERY_KEEPALIVE, RUN_QUERY_RESPONSE } from '../../../protocol'; import { BackendFunctionError } from '../../types'; -import { POSTMESSAGE_TIMEOUT_MS, postMessageTransport } from './post-message-transport'; +import { + POSTMESSAGE_MAX_WAIT_MS, + POSTMESSAGE_TIMEOUT_MS, + postMessageTransport, +} from './post-message-transport'; describe('postMessageTransport', () => { let originalFetch: typeof fetch; @@ -503,6 +507,7 @@ describe('postMessageTransport', () => { }); const OTHER_ORIGIN = 'https://example.com'; + const timedOut = { message: expect.stringContaining('timed out') }; const installTargetedCall = () => { const fakeWindow = installFakeWindow(); @@ -540,7 +545,6 @@ describe('postMessageTransport', () => { await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS); const error = await outcome; - const timedOut = { message: expect.stringContaining('timed out') }; expect(error).toBeInstanceOf(BackendFunctionError); expect(error).toMatchObject(timedOut); const remainingListeners = listenerCount(); @@ -584,6 +588,183 @@ describe('postMessageTransport', () => { await expect(second).resolves.toBe('second-result'); }); + describe('keepalive', () => { + type FakeWindow = ReturnType; + + const isPending = async (result: Promise) => { + const marker = Symbol('pending'); + const settledMarker = Promise.resolve(marker); + const outcome = await Promise.race([result, settledMarker]); + return outcome === marker; + }; + + beforeEach(() => { + jest.useFakeTimers(); + }); + + test("Should keep waiting past the timeout while the parent keeps the call's request alive", async () => { + const fakeWindow = installTargetedCall(); + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await fakeWindow.posted; + + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS - 1); + fakeWindow.keepAlive(message.requestId); + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS - 1); + + const stillPending = await isPending(result); + expect(stillPending).toBe(true); + fakeWindow.reply(message.requestId, 'ok'); + await expect(result).resolves.toBe('ok'); + }); + + test('Should time out a full timeout after the last keepalive', async () => { + const fakeWindow = installTargetedCall(); + const result = postMessageTransport(QUERY_NAME, ARGS); + const outcome = result.catch((error: unknown) => error); + const message = await fakeWindow.posted; + + await jest.advanceTimersByTimeAsync(1_000); + fakeWindow.keepAlive(message.requestId); + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS - 1); + const stillPending = await isPending(result); + expect(stillPending).toBe(true); + await jest.advanceTimersByTimeAsync(1); + + const error = await outcome; + expect(error).toBeInstanceOf(BackendFunctionError); + expect(error).toMatchObject(timedOut); + const remainingListeners = fakeWindow.listenerCount(); + expect(remainingListeners).toBe(0); + }); + + test("Should accept a keepalive from the parent at any origin when it posted with '*'", async () => { + const fakeWindow = installFakeWindow({}); + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await fakeWindow.posted; + + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS - 1); + fakeWindow.keepAlive(message.requestId, fakeWindow.parent, OTHER_ORIGIN); + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS - 1); + + const stillPending = await isPending(result); + expect(stillPending).toBe(true); + fakeWindow.reply(message.requestId, 'ok', fakeWindow.parent, OTHER_ORIGIN); + await expect(result).resolves.toBe('ok'); + }); + + const ignoredKeepAlives = [ + { + description: 'any window but the parent', + send: (fakeWindow: FakeWindow, requestId: string) => + fakeWindow.keepAlive(requestId, {}), + }, + { + description: 'the parent at another origin than the call was posted to', + send: (fakeWindow: FakeWindow, requestId: string) => + fakeWindow.keepAlive(requestId, fakeWindow.parent, OTHER_ORIGIN), + }, + { + description: 'the parent, for another request id', + send: (fakeWindow: FakeWindow) => fakeWindow.keepAlive('unknown-request-id'), + }, + ]; + + test.each(ignoredKeepAlives)( + 'Should ignore a keepalive from $description', + async ({ send }) => { + const fakeWindow = installTargetedCall(); + const result = postMessageTransport(QUERY_NAME, ARGS); + const outcome = result.catch((error: unknown) => error); + const message = await fakeWindow.posted; + + await jest.advanceTimersByTimeAsync(POSTMESSAGE_TIMEOUT_MS - 1); + send(fakeWindow, message.requestId); + await jest.advanceTimersByTimeAsync(1); + + const error = await outcome; + expect(error).toBeInstanceOf(BackendFunctionError); + expect(error).toMatchObject(timedOut); + }, + ); + + test('Should ignore a keepalive or reply that reaches the call after it settled', async () => { + const fakeWindow = installTargetedCall(); + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await fakeWindow.posted; + const [handleMessage] = fakeWindow.listeners(); + fakeWindow.reply(message.requestId, 'ok'); + await expect(result).resolves.toBe('ok'); + + // Calls the listener directly, so this holds even if it were still attached. + const sendLate = (data: unknown) => + handleMessage({ data, origin: DATADOG_PARENT, source: fakeWindow.parent }); + sendLate({ type: RUN_QUERY_KEEPALIVE, requestId: message.requestId }); + const timersAfterKeepAlive = jest.getTimerCount(); + expect(timersAfterKeepAlive).toBe(0); + sendLate({ type: RUN_QUERY_RESPONSE, requestId: message.requestId, success: false }); + + await jest.advanceTimersByTimeAsync(POSTMESSAGE_MAX_WAIT_MS); + await expect(result).resolves.toBe('ok'); + const remainingListeners = fakeWindow.listenerCount(); + expect(remainingListeners).toBe(0); + }); + + const sleeps = [ + { description: 'the timeout', sleepMs: POSTMESSAGE_TIMEOUT_MS }, + { description: 'the maximum wait', sleepMs: POSTMESSAGE_MAX_WAIT_MS }, + ]; + + test.each(sleeps)( + 'Should time out on a keepalive that arrives after $description passed while timers were paused', + async ({ sleepMs }) => { + const fakeWindow = installTargetedCall(); + const result = postMessageTransport(QUERY_NAME, ARGS); + const outcome = result.catch((error: unknown) => error); + const message = await fakeWindow.posted; + + // Moves the clock without running timers, as when the machine sleeps. + jest.setSystemTime(Date.now() + sleepMs); + fakeWindow.keepAlive(message.requestId); + + const remainingTimers = jest.getTimerCount(); + const remainingListeners = fakeWindow.listenerCount(); + expect(remainingTimers).toBe(0); + expect(remainingListeners).toBe(0); + const error = await outcome; + expect(error).toBeInstanceOf(BackendFunctionError); + expect(error).toMatchObject(timedOut); + }, + ); + + test('Should time out at the maximum wait however often the parent keeps the call alive', async () => { + const fakeWindow = installTargetedCall(); + const result = postMessageTransport(QUERY_NAME, ARGS); + const outcome = result.catch((error: unknown) => error); + const message = await fakeWindow.posted; + const interval = POSTMESSAGE_TIMEOUT_MS - 1; + + const keepAliveCount = Math.ceil(POSTMESSAGE_MAX_WAIT_MS / interval) - 1; + for (let count = 0; count < keepAliveCount; count++) { + // eslint-disable-next-line no-await-in-loop + await jest.advanceTimersByTimeAsync(interval); + fakeWindow.keepAlive(message.requestId); + } + await jest.advanceTimersByTimeAsync( + POSTMESSAGE_MAX_WAIT_MS - keepAliveCount * interval - 1, + ); + fakeWindow.keepAlive(message.requestId); + const stillPending = await isPending(result); + expect(stillPending).toBe(true); + await jest.advanceTimersByTimeAsync(1); + + const error = await outcome; + expect(error).toBeInstanceOf(BackendFunctionError); + expect(error).toMatchObject(timedOut); + const remainingTimers = jest.getTimerCount(); + expect(remainingTimers).toBe(0); + }); + }); + test('Should reject the call and stop waiting when the message cannot be posted', async () => { jest.useFakeTimers(); const { postMessage, listenerCount } = installFakeWindow(); diff --git a/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts b/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts index 4efd408bf..4807137bb 100644 --- a/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts +++ b/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts @@ -5,30 +5,45 @@ /* eslint-env browser */ /* global globalThis */ -import { DEV_CLIENT_HOOK, RUN_QUERY, RUN_QUERY_RESPONSE } from '../../../protocol'; +import { + DEV_CLIENT_HOOK, + RUN_QUERY, + RUN_QUERY_KEEPALIVE, + RUN_QUERY_RESPONSE, +} from '../../../protocol'; import type { DebugBundleResponse, PreparedCall } from '../../../protocol'; import { copyByIndex, copyDebugBundleActions, isDebugBundle, isRecord } from '../../guards'; import { generateRequestId } from '../../request-id'; import type { BackendFunctionTransport } from '../../types'; import { BackendFunctionError } from '../../types'; -import type { IframeQueryRequest, IframeQueryResponse } from './types'; +import type { IframeQueryKeepAlive, IframeQueryRequest, IframeQueryResponse } from './types'; export const POSTMESSAGE_TIMEOUT_MS = 120_000; +// A safety net for a parent that keeps a call alive but never answers, not a user deadline. +export const POSTMESSAGE_MAX_WAIT_MS = 60 * 60_000; -function isQueryResponse( - data: unknown, - requestId: string, -): data is IframeQueryResponse { +function isMessageFor(data: unknown, type: string, requestId: string): boolean { return ( isRecord(data) && 'type' in data && - data.type === RUN_QUERY_RESPONSE && + data.type === type && 'requestId' in data && data.requestId === requestId ); } +function isKeepAlive(data: unknown, requestId: string): data is IframeQueryKeepAlive { + return isMessageFor(data, RUN_QUERY_KEEPALIVE, requestId); +} + +function isQueryResponse( + data: unknown, + requestId: string, +): data is IframeQueryResponse { + return isMessageFor(data, RUN_QUERY_RESPONSE, requestId); +} + // Only an exact target origin restricts who receives the message, so a bundle is never posted with '*'. function isExactOrigin(targetOrigin: unknown): targetOrigin is string { if (typeof targetOrigin !== 'string') { @@ -95,13 +110,34 @@ function postRunQuery( const requestId = generateRequestId(); return new Promise((resolve, reject) => { - let timeoutId: ReturnType; + // Wall-clock deadlines, so time the machine spends asleep counts toward them. + const maxDeadline = Date.now() + POSTMESSAGE_MAX_WAIT_MS; + let deadline = Date.now() + POSTMESSAGE_TIMEOUT_MS; + let timeoutId: ReturnType | undefined; function cleanup(): void { window.removeEventListener('message', handleMessage); clearTimeout(timeoutId); } + function timeOut(): void { + cleanup(); + const timeoutError = new BackendFunctionError( + `Backend function "${functionName}" timed out waiting for response`, + functionName, + ); + reject(timeoutError); + } + + function checkDeadline(): void { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + timeOut(); + return; + } + timeoutId = setTimeout(checkDeadline, remainingMs); + } + function isFromRecipient(event: MessageEvent): boolean { return ( event.source === window.parent && @@ -110,8 +146,19 @@ function postRunQuery( } function handleMessage(event: MessageEvent): void { + if (!isFromRecipient(event)) { + return; + } const response = event.data; - if (!isFromRecipient(event) || !isQueryResponse(response, requestId)) { + if (isKeepAlive(response, requestId)) { + if (Date.now() >= deadline) { + timeOut(); + return; + } + deadline = Math.min(Date.now() + POSTMESSAGE_TIMEOUT_MS, maxDeadline); + return; + } + if (!isQueryResponse(response, requestId)) { return; } @@ -132,16 +179,7 @@ function postRunQuery( } window.addEventListener('message', handleMessage); - - timeoutId = setTimeout(() => { - cleanup(); - reject( - new BackendFunctionError( - `Backend function "${functionName}" timed out waiting for response`, - functionName, - ), - ); - }, POSTMESSAGE_TIMEOUT_MS); + checkDeadline(); const message: IframeQueryRequest = { type: RUN_QUERY, @@ -160,11 +198,11 @@ function postRunQuery( } /** - * Transport for executing backend functions via `postMessage` when the app is framed (e.g. App - * Builder preview): posts `app-builder:run-query` to the parent and waits up to - * {@link POSTMESSAGE_TIMEOUT_MS} for the matching response. On a dev server framed by a Datadog - * page, the injected dev client first adds the function's bundle and the parent's exact origin, - * which can take up to another {@link POSTMESSAGE_TIMEOUT_MS} before the call is sent without them. + * Transport for backend functions in a frame (e.g. App Builder preview): posts + * `app-builder:run-query` to the parent and waits {@link POSTMESSAGE_TIMEOUT_MS} for the response, + * re-armed by each keepalive up to {@link POSTMESSAGE_MAX_WAIT_MS}. On a Datadog-framed dev server, + * the injected dev client first adds the function's bundle and the parent's exact origin, which can + * take up to another {@link POSTMESSAGE_TIMEOUT_MS} before the call is sent without them. */ export const postMessageTransport: BackendFunctionTransport = ( functionName: string, diff --git a/packages/plugins/apps/src/backend/client/transports/post-message-transport/types.ts b/packages/plugins/apps/src/backend/client/transports/post-message-transport/types.ts index f52a22119..2c6c1e423 100644 --- a/packages/plugins/apps/src/backend/client/transports/post-message-transport/types.ts +++ b/packages/plugins/apps/src/backend/client/transports/post-message-transport/types.ts @@ -6,6 +6,7 @@ import type { DebugBundleResponse, ExecuteActionResponse, RUN_QUERY, + RUN_QUERY_KEEPALIVE, RUN_QUERY_RESPONSE, } from '../../../protocol'; @@ -24,3 +25,9 @@ export type IframeQueryResponse = { type: typeof RUN_QUERY_RESPONSE; requestId: string; } & ExecuteActionResponse; + +// Keepalive: parent → iframe, re-arms a pending request's timeout, e.g. while a consent dialog is open. +export type IframeQueryKeepAlive = { + type: typeof RUN_QUERY_KEEPALIVE; + requestId: string; +}; diff --git a/packages/plugins/apps/src/backend/protocol.ts b/packages/plugins/apps/src/backend/protocol.ts index 0fc57188a..208c880de 100644 --- a/packages/plugins/apps/src/backend/protocol.ts +++ b/packages/plugins/apps/src/backend/protocol.ts @@ -23,6 +23,7 @@ export const DEBUG_BUNDLE_PATH = '/__dd/debugBundle'; // Message types of the frame ↔ parent postMessage protocol. export const RUN_QUERY = 'app-builder:run-query'; export const RUN_QUERY_RESPONSE = 'app-builder:run-query:response'; +export const RUN_QUERY_KEEPALIVE = 'app-builder:run-query:keepalive'; export const PING = 'app-builder:ping'; export const PONG = 'app-builder:pong'; diff --git a/packages/tests/src/_jest/helpers/fakeWindow.ts b/packages/tests/src/_jest/helpers/fakeWindow.ts index ad863428b..db65e5958 100644 --- a/packages/tests/src/_jest/helpers/fakeWindow.ts +++ b/packages/tests/src/_jest/helpers/fakeWindow.ts @@ -4,7 +4,13 @@ /* global globalThis */ -import { PING, PONG, RUN_QUERY, RUN_QUERY_RESPONSE } from '@dd/apps-plugin/backend/protocol'; +import { + PING, + PONG, + RUN_QUERY, + RUN_QUERY_KEEPALIVE, + RUN_QUERY_RESPONSE, +} from '@dd/apps-plugin/backend/protocol'; type MessageListener = (event: { data: unknown; origin?: string; source?: unknown }) => void; export type PostedMessage = { type: string; requestId: string }; @@ -48,40 +54,37 @@ export function installFakeWindow( postMessage.mock.calls .map(([message]: [PostedMessage]) => message) .filter((message) => message.type === PING); - const pong = (origin = parentOrigin, source: unknown = fakeWindow.parent) => { - const ping = pings().at(-1); + const dispatch = (data: unknown, origin: string, source: unknown) => { for (const listener of [...listeners]) { - listener({ - data: { type: PONG, requestId: ping?.requestId }, - origin, - source, - }); + listener({ data, origin, source }); } }; + const pong = (origin = parentOrigin, source: unknown = fakeWindow.parent) => { + const ping = pings().at(-1); + dispatch({ type: PONG, requestId: ping?.requestId }, origin, source); + }; const reply = ( requestId: string, data: unknown, source: unknown = fakeWindow.parent, origin = parentOrigin, ) => { - for (const listener of [...listeners]) { - listener({ - data: { - type: RUN_QUERY_RESPONSE, - requestId, - success: true, - result: { data }, - }, - origin, - source, - }); - } + const response = { type: RUN_QUERY_RESPONSE, requestId, success: true, result: { data } }; + dispatch(response, origin, source); + }; + const keepAlive = ( + requestId: string, + source: unknown = fakeWindow.parent, + origin = parentOrigin, + ) => { + dispatch({ type: RUN_QUERY_KEEPALIVE, requestId }, origin, source); }; return { postMessage, posted, reply, + keepAlive, pings, pong, listeners: () => [...listeners],