Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/plugins/apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<unknown> = 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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -104,7 +114,7 @@ function pingParent(): Promise<void> {
}

// 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 &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -503,6 +507,7 @@ describe('postMessageTransport', () => {
});

const OTHER_ORIGIN = 'https://example.com';
const timedOut = { message: expect.stringContaining('timed out') };

const installTargetedCall = () => {
const fakeWindow = installFakeWindow();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -584,6 +588,183 @@ describe('postMessageTransport', () => {
await expect(second).resolves.toBe('second-result');
});

describe('keepalive', () => {
type FakeWindow = ReturnType<typeof installFakeWindow>;

const isPending = async (result: Promise<unknown>) => {
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();
Expand Down
Loading
Loading