diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 02cc9c462..542d44cb7 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -19,7 +19,7 @@ "./*": "./src/*.ts" }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.client.json" }, "dependencies": { "@dd/core": "workspace:*", diff --git a/packages/plugins/apps/src/backend/client/execute-backend-function.test.ts b/packages/plugins/apps/src/backend/client/execute-backend-function.test.ts new file mode 100644 index 000000000..be1504dde --- /dev/null +++ b/packages/plugins/apps/src/backend/client/execute-backend-function.test.ts @@ -0,0 +1,126 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +// Only the fetch (dev-server) transport is exercised here. The iframe +// postMessage transport requires a DOM (`window`, `MessageEvent`) that this +// repo's node-only jest harness doesn't provide — adding jsdom collides with +// the shared `setupAfterEnv.ts` (nock → TextEncoder). postMessage coverage +// lives with the original tests in web-ui's @datadog/apps-function-query +// until a DOM-enabled harness is introduced. + +import { executeBackendFunction } from './execute-backend-function'; +import { BackendFunctionError } from './types'; + +describe('executeBackendFunction', () => { + let originalFetch: typeof fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + it('should successfully execute a backend function', async () => { + const mockResponse = { success: true, result: { data: { sum: 12 } } }; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + const result = await executeBackendFunction<{ sum: number }>('testWithImport', [5, 7]); + + expect(result).toEqual({ sum: 12 }); + expect(global.fetch).toHaveBeenCalledWith('/__dd/executeAction', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + functionName: 'testWithImport', + args: [5, 7], + }), + }); + }); + + it('should throw BackendFunctionError on network error', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network failed')); + + await expect(executeBackendFunction('testFunction', [])).rejects.toThrow( + BackendFunctionError, + ); + + await expect(executeBackendFunction('testFunction', [])).rejects.toThrow( + 'Network error while executing backend function "testFunction"', + ); + }); + + it('should throw BackendFunctionError on non-ok response', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 500, + text: async () => 'Internal Server Error', + }); + + await expect(executeBackendFunction('testFunction', [])).rejects.toThrow( + BackendFunctionError, + ); + + await expect(executeBackendFunction('testFunction', [])).rejects.toThrow( + 'Backend function "testFunction" failed with status 500', + ); + }); + + it('should throw BackendFunctionError when response contains error field', async () => { + const mockResponse = { + error: 'Function execution failed', + data: null, + }; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => mockResponse, + }); + + await expect(executeBackendFunction('testFunction', [])).rejects.toThrow( + BackendFunctionError, + ); + + await expect(executeBackendFunction('testFunction', [])).rejects.toThrow( + 'Backend function "testFunction" returned an error', + ); + }); + + it('should throw BackendFunctionError on invalid JSON response', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new Error('Invalid JSON'); + }, + }); + + await expect(executeBackendFunction('testFunction', [])).rejects.toThrow( + BackendFunctionError, + ); + + await expect(executeBackendFunction('testFunction', [])).rejects.toThrow( + 'Failed to parse response from backend function', + ); + }); + + it('should include statusCode in error when available', async () => { + global.fetch = jest.fn().mockResolvedValue({ + ok: false, + status: 404, + text: async () => 'Not Found', + }); + + await expect(executeBackendFunction('testFunction', [])).rejects.toMatchObject({ + name: 'BackendFunctionError', + functionName: 'testFunction', + statusCode: 404, + }); + }); +}); diff --git a/packages/plugins/apps/src/backend/client/execute-backend-function.ts b/packages/plugins/apps/src/backend/client/execute-backend-function.ts new file mode 100644 index 000000000..666f1da81 --- /dev/null +++ b/packages/plugins/apps/src/backend/client/execute-backend-function.ts @@ -0,0 +1,54 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* eslint-env browser */ + +import { devServerTransport } from './transports/dev-server-transport'; +import { postMessageTransport } from './transports/post-message-transport/post-message-transport'; +import type { BackendFunctionTransport } from './types'; + +function isInIframe(): boolean { + try { + return typeof window !== 'undefined' && window.parent !== window; + } catch { + // Accessing window.parent can throw if cross-origin + return true; + } +} + +function resolveTransport(): BackendFunctionTransport { + if (isInIframe()) { + return postMessageTransport; + } + return devServerTransport; +} + +/** + * Executes a backend function by name with the provided arguments. + * + * When running inside an iframe embedded in App Builder, automatically + * uses postMessage to communicate with the parent window. Otherwise, + * uses HTTP fetch to the backend endpoint. + * + * @param functionName - The name of the backend function to execute + * @param args - Array of arguments to pass to the function + * @returns Promise that resolves to the function's return value + * @throws {BackendFunctionError} If the request fails or the function throws an error + * + * @example + * ```typescript + * const result = await executeBackendFunction<{ sum: number }, [number, number]>( + * 'testWithImport', + * [5, 7] + * ); + * console.log(result.sum); // 12 + * ``` + */ +export async function executeBackendFunction( + functionName: string, + args: TArgs, +): Promise { + const transport = resolveTransport(); + return transport(functionName, args); +} diff --git a/packages/plugins/apps/src/backend/client/transports/dev-server-transport.ts b/packages/plugins/apps/src/backend/client/transports/dev-server-transport.ts new file mode 100644 index 000000000..3ed187233 --- /dev/null +++ b/packages/plugins/apps/src/backend/client/transports/dev-server-transport.ts @@ -0,0 +1,73 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { ExecuteActionRequest, ExecuteActionResponse } from '../../protocol'; +import type { BackendFunctionTransport } from '../types'; +import { BackendFunctionError } from '../types'; + +const ENDPOINT = '/__dd/executeAction'; + +export const devServerTransport: BackendFunctionTransport = async ( + functionName: string, + args: unknown[], +): Promise => { + const request: ExecuteActionRequest = { + functionName, + args, + }; + + let response: Response; + try { + response = await fetch(ENDPOINT, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(request), + }); + } catch (error) { + throw new BackendFunctionError( + `Network error while executing backend function "${functionName}": ${ + error instanceof Error ? error.message : String(error) + }`, + functionName, + ); + } + + if (!response.ok) { + let errorMessage = `Backend function "${functionName}" failed with status ${response.status}`; + try { + const errorBody = await response.text(); + if (errorBody) { + errorMessage += `: ${errorBody}`; + } + } catch { + // Ignore errors reading error body + } + throw new BackendFunctionError(errorMessage, functionName, response.status); + } + + let executeActionResponse: ExecuteActionResponse; + try { + executeActionResponse = await response.json(); + } catch (error) { + throw new BackendFunctionError( + `Failed to parse response from backend function "${functionName}": ${ + error instanceof Error ? error.message : String(error) + }`, + functionName, + response.status, + ); + } + + if (!executeActionResponse.success) { + throw new BackendFunctionError( + `Backend function "${functionName}" returned an error: ${executeActionResponse.error}`, + functionName, + response.status, + ); + } + + return executeActionResponse.result.data; +}; 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 new file mode 100644 index 000000000..f08666659 --- /dev/null +++ b/packages/plugins/apps/src/backend/client/transports/post-message-transport/post-message-transport.ts @@ -0,0 +1,99 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/* eslint-env browser */ + +import type { BackendFunctionTransport } from '../../types'; +import { BackendFunctionError } from '../../types'; + +import type { IframeQueryResponse } from './types'; + +const POSTMESSAGE_TIMEOUT_MS = 120_000; + +let requestCounter = 0; + +function generateRequestId(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + requestCounter += 1; + return `req-${Date.now()}-${requestCounter}`; +} + +function isQueryResponse(data: unknown, requestId: string): data is IframeQueryResponse { + return ( + data !== null && + typeof data === 'object' && + 'type' in data && + data.type === 'app-builder:run-query:response' && + 'requestId' in data && + data.requestId === requestId + ); +} + +/** + * Transport for executing backend functions via `postMessage` when the app + * is hosted inside an iframe (e.g. App Builder preview). Sends a + * `app-builder:run-query` message to the parent window and listens for a + * matching `app-builder:run-query:response` reply. Rejects if no response + * arrives within {@link POSTMESSAGE_TIMEOUT_MS}. + */ +export const postMessageTransport: BackendFunctionTransport = ( + functionName: string, + args: unknown[], +): Promise => { + const requestId = generateRequestId(); + + return new Promise((resolve, reject) => { + let timeoutId: ReturnType; + + function cleanup(): void { + window.removeEventListener('message', handleMessage); + clearTimeout(timeoutId); + } + + function handleMessage(event: MessageEvent): void { + if (!isQueryResponse(event.data, requestId)) { + return; + } + + cleanup(); + + const response = event.data as IframeQueryResponse; + + if (response.success) { + resolve(response.result.data); + } else { + reject( + new BackendFunctionError( + response.error ?? `Backend function "${functionName}" failed`, + functionName, + ), + ); + } + } + + window.addEventListener('message', handleMessage); + + timeoutId = setTimeout(() => { + cleanup(); + reject( + new BackendFunctionError( + `Backend function "${functionName}" timed out waiting for response`, + functionName, + ), + ); + }, POSTMESSAGE_TIMEOUT_MS); + + window.parent.postMessage( + { + type: 'app-builder:run-query', + requestId, + queryName: functionName, + args, + }, + '*', + ); + }); +}; 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 new file mode 100644 index 000000000..658c8ec99 --- /dev/null +++ b/packages/plugins/apps/src/backend/client/transports/post-message-transport/types.ts @@ -0,0 +1,35 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import type { ExecuteActionResponse } from '../../../protocol'; + +// Request: iframe → parent +export type IframeQueryRequest = { + type: 'app-builder:run-query'; + requestId: string; + queryName: string; + args?: unknown[]; + templateParams?: Record; +}; + +export type IframeQueryPing = { + type: 'app-builder:ping'; + requestId: string; +}; + +export type IframeToParentMessage = IframeQueryRequest | IframeQueryPing; + +// Response: parent → iframe +export type IframeQueryResponse = { + type: 'app-builder:run-query:response'; + requestId: string; +} & ExecuteActionResponse; + +export type IframeQueryPong = { + type: 'app-builder:pong'; + requestId: string; + availableQueries: string[]; +}; + +export type ParentToIframeMessage = IframeQueryResponse | IframeQueryPong; diff --git a/packages/plugins/apps/src/backend/client/types.ts b/packages/plugins/apps/src/backend/client/types.ts new file mode 100644 index 000000000..d3b58a982 --- /dev/null +++ b/packages/plugins/apps/src/backend/client/types.ts @@ -0,0 +1,25 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/** + * A transport function that executes a backend function via a specific mechanism. + */ +export type BackendFunctionTransport = ( + functionName: string, + args: unknown[], +) => Promise; + +/** + * Error thrown when backend function execution fails + */ +export class BackendFunctionError extends Error { + constructor( + message: string, + public functionName: string, + public statusCode?: number, + ) { + super(message); + this.name = 'BackendFunctionError'; + } +} diff --git a/packages/plugins/apps/src/backend/protocol.ts b/packages/plugins/apps/src/backend/protocol.ts new file mode 100644 index 000000000..04e8751fa --- /dev/null +++ b/packages/plugins/apps/src/backend/protocol.ts @@ -0,0 +1,38 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/** + * Wire protocol for backend function execution shared by the dev server, + * the dev-server transport, and the iframe postMessage transport. + * + * Lives at the backend/ root (above client/) so server-side and client-side + * code can both depend on it without one importing through the other. + */ + +/** + * Request payload for executing a backend function. + */ +export interface ExecuteActionRequest { + functionName: string; + args: unknown[]; +} + +/** + * Response from executing a backend function. + * + * The `data` wrapper inside `result` mirrors the Datadog app-builder + * `preview-async` queries API contract: a JS action's return value is + * surfaced as `outputs: { data: }`. Both transports unwrap `.data`. + */ +export type ExecuteActionResponse = + | { + success: true; + result: { + data: TData; + }; + } + | { + success: false; + error: string; + }; diff --git a/packages/plugins/apps/tsconfig.client.json b/packages/plugins/apps/tsconfig.client.json new file mode 100644 index 000000000..ca2406e65 --- /dev/null +++ b/packages/plugins/apps/tsconfig.client.json @@ -0,0 +1,14 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "baseUrl": "./", + "rootDir": "./", + "outDir": "./dist", + "lib": ["es2022", "dom"], + "composite": true, + "noEmit": false + }, + "files": ["src/backend/protocol.ts"], + "include": ["src/backend/client/**/*"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/plugins/apps/tsconfig.json b/packages/plugins/apps/tsconfig.json index 6c1d3065e..8c66ff72d 100644 --- a/packages/plugins/apps/tsconfig.json +++ b/packages/plugins/apps/tsconfig.json @@ -6,5 +6,6 @@ "outDir": "./dist" }, "include": ["**/*"], - "exclude": ["dist", "node_modules"] + "exclude": ["dist", "node_modules", "src/backend/client"], + "references": [{ "path": "./tsconfig.client.json" }] } \ No newline at end of file