diff --git a/packages/plugins/apps/src/backend/ast-parsing/action-catalog-fqns.test.ts b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-fqns.test.ts new file mode 100644 index 000000000..02abb337b --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-fqns.test.ts @@ -0,0 +1,90 @@ +// 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 { parseAst } from 'rollup/parseAst'; + +import { collectActionCatalogExportFqns } from './action-catalog-fqns'; +import { ensureProgram } from './type-guards'; + +function collect(code: string): Record { + const ast = parseAst(code); + const program = ensureProgram(ast, 'fixture.js'); + const fqnsByExport = collectActionCatalogExportFqns(program); + return Object.fromEntries(fqnsByExport); +} + +describe('Backend Functions - collectActionCatalogExportFqns', () => { + const cases: { description: string; code: string; expected: Record }[] = [ + { + description: "map each export of the published package's minified module shape", + code: 'import{executeAction as t}from"../../action-execution";function r(e){return t("com.datadoghq.http.getChecksumFromURL",e)}function o(e){return t("com.datadoghq.http.request",e)}export{r as getChecksumFromURL,o as request};', + expected: { + getChecksumFromURL: 'com.datadoghq.http.getChecksumFromURL', + request: 'com.datadoghq.http.request', + }, + }, + { + description: 'map an exported async function declaration', + code: ` + import { getExecuteActionImplementation } from './action-execution.js'; + export async function sendSlackMessage(request) { + const implementation = getExecuteActionImplementation(); + if (!implementation) { + throw new Error('no implementation'); + } + return implementation('com.datadoghq.slack.chat.postMessage', request); + } + `, + expected: { sendSlackMessage: 'com.datadoghq.slack.chat.postMessage' }, + }, + { + description: 'map exported const arrow and function expressions', + code: ` + import { executeAction } from './action-execution.js'; + export const listObjects = (e) => executeAction('com.datadoghq.aws.s3.listObjects', e); + const deleteChat = function (e) { return executeAction('com.datadoghq.slack.chat.deleteChat', e); }; + export { deleteChat }; + `, + expected: { + deleteChat: 'com.datadoghq.slack.chat.deleteChat', + listObjects: 'com.datadoghq.aws.s3.listObjects', + }, + }, + { + description: 'skip a function calling more than one distinct action', + code: ` + export function both(e) { + run('com.datadoghq.http.request', e); + return run('com.datadoghq.slack.chat.postMessage', e); + } + `, + expected: {}, + }, + { + description: 'skip a function whose first call argument is not an action id', + code: ` + export function notAnAction(e) { return run('slack.chat.postMessage', e); } + export function dynamic(name, e) { return run(name, e); } + `, + expected: {}, + }, + { + description: 'skip re-exports, default exports and non-function exports', + code: ` + export { request } from './http.js'; + export * from './slack.js'; + export default function (e) { return run('com.datadoghq.http.request', e); } + export const FQN = 'com.datadoghq.http.request'; + let mutable = (e) => run('com.datadoghq.http.request', e); + export { mutable }; + `, + expected: {}, + }, + ]; + + test.each(cases)('Should $description', ({ code, expected }) => { + const fqnsByExport = collect(code); + expect(fqnsByExport).toEqual(expected); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/action-catalog-fqns.ts b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-fqns.ts new file mode 100644 index 000000000..af2ebf3f6 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-fqns.ts @@ -0,0 +1,94 @@ +// 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 { Node, Program } from 'estree'; + +import { isStringLiteral } from './type-guards'; +import { walkAst } from './walk-ast'; + +export const ACTION_FQN_PREFIX = 'com.datadoghq.'; + +type FunctionNode = Extract< + Node, + { type: 'FunctionDeclaration' | 'FunctionExpression' | 'ArrowFunctionExpression' } +>; + +/** + * Maps each exported function of an `@datadog/action-catalog` module to the one `com.datadoghq.*` + * action FQN it calls the executor with. The import path alone doesn't name the action: + * `aws/agent` exports `com.datadoghq.aws.bedrock.agent.*`. + */ +export function collectActionCatalogExportFqns(ast: Program): Map { + const functionsByName = collectTopLevelFunctions(ast); + const fqnsByExport = new Map(); + const addExport = (exportedName: string, fn: FunctionNode | undefined) => { + const fqn = fn && getSingleActionFqn(fn); + if (fqn) { + fqnsByExport.set(exportedName, fqn); + } + }; + + for (const node of ast.body) { + if (node.type !== 'ExportNamedDeclaration' || node.source) { + continue; + } + if (node.declaration?.type === 'FunctionDeclaration' && node.declaration.id) { + addExport(node.declaration.id.name, node.declaration); + } + if (node.declaration?.type === 'VariableDeclaration') { + for (const declarator of node.declaration.declarations) { + if (declarator.id.type === 'Identifier') { + const fn = functionsByName.get(declarator.id.name); + addExport(declarator.id.name, fn); + } + } + } + for (const specifier of node.specifiers) { + if (specifier.local.type !== 'Identifier' || specifier.exported.type !== 'Identifier') { + continue; + } + const fn = functionsByName.get(specifier.local.name); + addExport(specifier.exported.name, fn); + } + } + + return fqnsByExport; +} + +function collectTopLevelFunctions(ast: Program): Map { + const functionsByName = new Map(); + for (const statement of ast.body) { + const node = + statement.type === 'ExportNamedDeclaration' ? statement.declaration : statement; + if (node?.type === 'FunctionDeclaration' && node.id) { + functionsByName.set(node.id.name, node); + } + if (node?.type !== 'VariableDeclaration' || node.kind !== 'const') { + continue; + } + for (const declarator of node.declarations) { + const init = declarator.init; + if ( + declarator.id.type === 'Identifier' && + (init?.type === 'ArrowFunctionExpression' || init?.type === 'FunctionExpression') + ) { + functionsByName.set(declarator.id.name, init); + } + } + } + return functionsByName; +} + +function getSingleActionFqn(fn: FunctionNode): string | undefined { + const fqns = new Set(); + walkAst(fn.body, fqns, { + CallExpression(call, { state }) { + const [firstArg] = call.arguments; + if (isStringLiteral(firstArg) && firstArg.value.startsWith(ACTION_FQN_PREFIX)) { + state.add(firstArg.value); + } + }, + }); + return fqns.size === 1 ? [...fqns][0] : undefined; +} diff --git a/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts index bb8ada84d..b9bddfae9 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/action-catalog-imports.ts @@ -6,7 +6,7 @@ import type { Program } from 'estree'; import { isTypeOnly } from './type-guards'; -const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; +export const ACTION_CATALOG_PACKAGE = '@datadog/action-catalog'; export interface ActionCatalogImports { functions: Set; diff --git a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts index f8c991882..c451fb1ea 100644 --- a/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts +++ b/packages/plugins/apps/src/backend/ast-parsing/extract-connection-ids-from-module-graph.ts @@ -24,6 +24,12 @@ const OPTION_HOOK_KEYS: ReadonlySet = new Set(['__proto__', 'toJSON', 't type ConnectionIdProperty = Property & { value: Expression }; +export interface ActionCatalogCall { + record: ParsedModuleRecord; + callSite: SimpleCallExpression; + connectionId: string | undefined; +} + /** * Extracts the conservative backend-file connection ID union from module records * collected while the backend bundler walked the real execution graph. @@ -33,7 +39,30 @@ export function extractConnectionIdsFromModuleGraph( modules: ReadonlyMap, buildRoot: string, ): string[] { + const calls = collectActionCatalogCalls(entryId, modules, buildRoot); + return getConnectionIds(calls); +} + +export function getConnectionIds(calls: ActionCatalogCall[]): string[] { const connectionIds = new Set(); + + for (const { connectionId } of calls) { + // `''` is a valid declared connectionId, not "none" — only the absence of a + // `connectionId` property (`undefined`) means no restriction to record. + if (connectionId !== undefined) { + connectionIds.add(connectionId); + } + } + + return [...connectionIds].sort(); +} + +export function collectActionCatalogCalls( + entryId: string, + modules: ReadonlyMap, + buildRoot: string, +): ActionCatalogCall[] { + const calls: ActionCatalogCall[] = []; let mutatedImportedObjectVariables: Set | undefined; const getMutatedImportedObjectVariables = () => { mutatedImportedObjectVariables ??= collectMutatedImportedObjectVariables(modules); @@ -54,15 +83,11 @@ export function extractConnectionIdsFromModuleGraph( record, getMutatedImportedObjectVariables, ); - // `''` is a valid declared connectionId, not "none" — only the absence of a - // `connectionId` property (`undefined`) means no restriction to record. - if (connectionId !== undefined) { - connectionIds.add(connectionId); - } + calls.push({ record, callSite, connectionId }); } }); - return [...connectionIds].sort(); + return calls; } function extractConnectionIdFromActionCall( diff --git a/packages/plugins/apps/src/backend/ast-parsing/list-action-calls.test.ts b/packages/plugins/apps/src/backend/ast-parsing/list-action-calls.test.ts new file mode 100644 index 000000000..ec1475c45 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/list-action-calls.test.ts @@ -0,0 +1,146 @@ +// 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 { createFixtureRecord } from '@dd/tests/_jest/helpers/moduleGraph'; + +import type { DebugBundleAction } from '../protocol'; + +import { collectActionCatalogCalls } from './extract-connection-ids-from-module-graph'; +import { listActionCalls } from './list-action-calls'; +import type { ParsedModuleRecord } from './module-graph'; + +const buildRoot = '/project'; +const entryId = '/project/src/backend/actions.backend.js'; +const helperId = '/project/src/backend/helpers/slack.js'; +const HTTP_MODULE = '@datadog/action-catalog/http/http'; +const ROOT_MODULE = '@datadog/action-catalog'; + +const exportFqnsByModule = new Map([ + [HTTP_MODULE, new Map([['request', 'com.datadoghq.http.request']])], + [ROOT_MODULE, new Map([['sendSlackMessage', 'com.datadoghq.slack.chat.postMessage']])], +]); + +const createRecord = (id: string, code: string, resolvedIds: string[] = []): ParsedModuleRecord => + createFixtureRecord(id, buildRoot, code, resolvedIds); + +function extract( + records: ParsedModuleRecord[], + fqns: ReadonlyMap> = exportFqnsByModule, +): DebugBundleAction[] | undefined { + const moduleEntries = records.map((record): [string, ParsedModuleRecord] => [ + record.id, + record, + ]); + const modules = new Map(moduleEntries); + const calls = collectActionCatalogCalls(entryId, modules, buildRoot); + return listActionCalls(calls, fqns); +} + +describe('Backend Functions - listActionCalls', () => { + test('Should list named and namespace action-catalog calls across helpers, ordered by fqn', () => { + const entry = createRecord( + entryId, + ` + import { notify } from './helpers/slack.js'; + import * as http from '@datadog/action-catalog/http/http'; + + export async function run() { + await http.request({ connectionId: 'conn-http', inputs: {} }); + return notify(); + } + `, + [helperId, HTTP_MODULE], + ); + const helper = createRecord( + helperId, + ` + import { sendSlackMessage } from '@datadog/action-catalog'; + + export function notify() { + return sendSlackMessage({ connectionId: 'conn-slack', inputs: {} }); + } + `, + [ROOT_MODULE], + ); + + const actions = extract([entry, helper]); + expect(actions).toEqual([ + { fqn: 'com.datadoghq.http.request', connectionIds: ['conn-http'] }, + { fqn: 'com.datadoghq.slack.chat.postMessage', connectionIds: ['conn-slack'] }, + ]); + }); + + test('Should merge repeated calls to one action and keep calls with no connectionId', () => { + const entry = createRecord( + entryId, + ` + import { request as callHttp } from '@datadog/action-catalog/http/http'; + import { sendSlackMessage } from '@datadog/action-catalog'; + + export function run() { + callHttp({ connectionId: 'conn-b', inputs: {} }); + callHttp({ connectionId: 'conn-a', inputs: {} }); + callHttp({ connectionId: 'conn-b', inputs: {} }); + return sendSlackMessage({ inputs: {} }); + } + `, + [HTTP_MODULE, ROOT_MODULE], + ); + + const actions = extract([entry]); + expect(actions).toEqual([ + { fqn: 'com.datadoghq.http.request', connectionIds: ['conn-a', 'conn-b'] }, + { fqn: 'com.datadoghq.slack.chat.postMessage', connectionIds: [] }, + ]); + }); + + test('Should return an empty list for a function with no action-catalog calls', () => { + const entry = createRecord(entryId, 'export function run() { return 1; }'); + + const actions = extract([entry]); + expect(actions).toEqual([]); + }); + + const unresolvedCases = [ + { + description: 'the imported package module has no export table', + code: ` + import { request } from '@datadog/action-catalog/http/http'; + export function run() { return request({ inputs: {} }); } + `, + fqns: new Map(), + }, + { + description: 'the imported export has no known fqn', + code: ` + import { getChecksumFromURL } from '@datadog/action-catalog/http/http'; + export function run() { return getChecksumFromURL({ inputs: {} }); } + `, + fqns: exportFqnsByModule, + }, + { + description: 'a namespace call goes through a nested member', + code: ` + import * as http from '@datadog/action-catalog/http/http'; + export function run() { return http.request.call({ inputs: {} }); } + `, + fqns: exportFqnsByModule, + }, + { + description: 'the action is a default import', + code: ` + import request from '@datadog/action-catalog/http/http'; + export function run() { return request({ inputs: {} }); } + `, + fqns: exportFqnsByModule, + }, + ]; + + test.each(unresolvedCases)('Should return undefined when $description', ({ code, fqns }) => { + const entry = createRecord(entryId, code, [HTTP_MODULE]); + + const actions = extract([entry], fqns); + expect(actions).toBeUndefined(); + }); +}); diff --git a/packages/plugins/apps/src/backend/ast-parsing/list-action-calls.ts b/packages/plugins/apps/src/backend/ast-parsing/list-action-calls.ts new file mode 100644 index 000000000..312a12a50 --- /dev/null +++ b/packages/plugins/apps/src/backend/ast-parsing/list-action-calls.ts @@ -0,0 +1,67 @@ +// 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 { Identifier } from 'estree'; + +import type { DebugBundleAction } from '../protocol'; + +import type { ActionCatalogCall } from './extract-connection-ids-from-module-graph'; +import type { ParsedModuleRecord } from './module-graph'; +import { resolveIdentifier } from './module-scope'; + +/** + * Groups action-catalog calls by action FQN, with the connection IDs bound to each. + * Returns undefined when a call has no known FQN, so a list missing it is never returned. + */ +export function listActionCalls( + calls: ActionCatalogCall[], + exportFqnsByModule: ReadonlyMap>, +): DebugBundleAction[] | undefined { + const connectionIdsByFqn = new Map>(); + + for (const call of calls) { + const fqn = resolveCallFqn(call, exportFqnsByModule); + if (!fqn) { + return undefined; + } + const connectionIds = connectionIdsByFqn.get(fqn) ?? new Set(); + if (call.connectionId !== undefined) { + connectionIds.add(call.connectionId); + } + connectionIdsByFqn.set(fqn, connectionIds); + } + + return [...connectionIdsByFqn] + .map(([fqn, connectionIds]) => ({ fqn, connectionIds: [...connectionIds].sort() })) + .sort((a, b) => (a.fqn < b.fqn ? -1 : 1)); +} + +function resolveCallFqn( + { record, callSite }: ActionCatalogCall, + exportFqnsByModule: ReadonlyMap>, +): string | undefined { + const { callee } = callSite; + if (callee.type === 'Identifier') { + const binding = getImportBinding(callee, record); + return binding?.kind === 'named' + ? exportFqnsByModule.get(binding.resolvedId)?.get(binding.importedName) + : undefined; + } + if ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.property.type === 'Identifier' + ) { + const binding = getImportBinding(callee.object, record); + return binding?.kind === 'namespace' + ? exportFqnsByModule.get(binding.resolvedId)?.get(callee.property.name) + : undefined; + } + return undefined; +} + +function getImportBinding(identifier: Identifier, record: ParsedModuleRecord) { + const variable = resolveIdentifier(identifier, record.scopeAnalysis); + return variable && record.importsByVariable.get(variable); +} 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 30172312f..bf6f91efe 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 @@ -651,6 +651,44 @@ describe('dev client', () => { expect(message).toHaveProperty('bundle', BUNDLE); }); + test('Should forward the action list with the bundle', async () => { + const { posted, reply } = installFakeWindow(); + const actions = [{ fqn: 'com.datadoghq.http.request', connectionIds: ['conn-1'] }]; + mockFetchResponse({ ok: true, json: async () => ({ ...BUNDLE, actions }) }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + await result; + + expect(message).toHaveProperty('bundle', { ...BUNDLE, actions }); + }); + + const malformedActionLists = [ + { description: 'not a list', actions: { fqn: 'com.datadoghq.http.request' } }, + { description: 'an action without an fqn', actions: [{ connectionIds: [] }] }, + { + description: 'non-string connection IDs', + actions: [{ fqn: 'com.datadoghq.http.request', connectionIds: [1] }], + }, + ]; + + test.each(malformedActionLists)( + 'Should forward the bundle without its action list when that list is $description', + async ({ actions }) => { + const { posted, reply } = installFakeWindow(); + mockFetchResponse({ ok: true, json: async () => ({ ...BUNDLE, actions }) }); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + await result; + + expect(message).toHaveProperty('bundle', BUNDLE); + expect(warn).not.toHaveBeenCalled(); + }, + ); + const failureCases = [ { description: 'the fetch rejects', 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 ec0ba1bce..9deeff41c 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 @@ -13,7 +13,7 @@ import type { IframeQueryPing, PrepareCall, } from '../../protocol'; -import { isDebugBundle, isRecord } from '../guards'; +import { copyDebugBundleActions, isDebugBundle, isRecord } from '../guards'; import { generateRequestId } from '../request-id'; export const BUNDLE_FETCH_TIMEOUT_MS = 60_000; @@ -188,7 +188,9 @@ async function fetchOwnBundle(functionName: string): Promise { if (!isDebugBundle(body)) { return { failure: 'the dev server response is not a bundle' }; } - return { bundle: { code: body.code, allowedConnectionIds: body.allowedConnectionIds } }; + const actions = copyDebugBundleActions(body); + const { code, allowedConnectionIds } = body; + return { bundle: { code, allowedConnectionIds, ...(actions && { actions }) } }; } catch (error: unknown) { const errorMessage = error instanceof Error ? error.message : String(error); return { failure: errorMessage }; diff --git a/packages/plugins/apps/src/backend/client/guards.ts b/packages/plugins/apps/src/backend/client/guards.ts index 1748e2db8..28fa66109 100644 --- a/packages/plugins/apps/src/backend/client/guards.ts +++ b/packages/plugins/apps/src/backend/client/guards.ts @@ -2,7 +2,7 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -import type { DebugBundleResponse } from '../protocol'; +import type { DebugBundleAction, DebugBundleResponse } from '../protocol'; export function isRecord(data: unknown): data is object { return data !== null && typeof data === 'object'; @@ -17,6 +17,10 @@ export function copyByIndex(list: unknown[]): unknown[] { return copy; } +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item: unknown) => typeof item === 'string'); +} + export function isDebugBundle(data: unknown): data is DebugBundleResponse { return ( isRecord(data) && @@ -24,7 +28,40 @@ export function isDebugBundle(data: unknown): data is DebugBundleResponse { typeof data.code === 'string' && data.code.length > 0 && 'allowedConnectionIds' in data && - Array.isArray(data.allowedConnectionIds) && - data.allowedConnectionIds.every((id: unknown) => typeof id === 'string') + isStringArray(data.allowedConnectionIds) ); } + +function isDebugBundleAction(value: unknown): value is DebugBundleAction { + return ( + isRecord(value) && + 'fqn' in value && + typeof value.fqn === 'string' && + 'connectionIds' in value && + isStringArray(value.connectionIds) + ); +} + +// A malformed list is dropped on its own, so the bundle it came with is still sent. +export function copyDebugBundleActions(bundle: object): DebugBundleAction[] | undefined { + try { + const actions = 'actions' in bundle ? bundle.actions : undefined; + if (!Array.isArray(actions)) { + return undefined; + } + const actionsCopy = copyByIndex(actions); + const copy = actionsCopy.map((action) => { + if (!isRecord(action)) { + return undefined; + } + const ids = 'connectionIds' in action ? action.connectionIds : undefined; + return { + fqn: 'fqn' in action ? action.fqn : undefined, + connectionIds: Array.isArray(ids) ? copyByIndex(ids) : undefined, + }; + }); + return copy.every(isDebugBundleAction) ? copy : undefined; + } catch { + return undefined; + } +} 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 39d766f2d..f8493e80e 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 @@ -107,6 +107,74 @@ describe('postMessageTransport', () => { expect(message).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); }); + test("Should post only the checked fields of the dev client's action list", async () => { + const { posted, reply } = installFakeWindow(); + const action = { fqn: 'com.datadoghq.http.request', connectionIds: ['conn-1'] }; + const extendedActions = [{ ...action, extra: () => 'not cloneable' }]; + const bundle = { ...BUNDLE, actions: extendedActions }; + const prepareCall = jest.fn().mockResolvedValue({ targetOrigin: DATADOG_PARENT, bundle }); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual({ + ...MESSAGE_WITHOUT_BUNDLE, + bundle: { ...BUNDLE, actions: [action] }, + }); + }); + + test("Should copy the dev client's action list without running that array's own methods", async () => { + const { posted, reply } = installFakeWindow(); + const action = { fqn: 'com.datadoghq.http.request', connectionIds: ['conn-1'] }; + const forgedAction = { fqn: 'com.datadoghq.forged', connectionIds: ['conn-forged'] }; + class ForgingArray extends Array {} + Reflect.defineProperty(ForgingArray, Symbol.species, { + get: () => + function forgedArray() { + return new globalThis.Proxy([forgedAction], { + defineProperty: () => true, + set: () => true, + }); + }, + }); + const actions = ForgingArray.from([action]); + const bundle = { ...BUNDLE, actions }; + const prepareCall = jest.fn().mockResolvedValue({ targetOrigin: DATADOG_PARENT, bundle }); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual({ + ...MESSAGE_WITHOUT_BUNDLE, + bundle: { ...BUNDLE, actions: [action] }, + }); + }); + + test('Should post the bundle without its action list when reading that list throws', async () => { + const { postMessage, posted, reply } = installFakeWindow(); + const bundle = Object.defineProperty({ ...BUNDLE }, 'actions', { + get(): never { + throw new Error('unreadable'); + }, + }); + const prepareCall = jest.fn().mockResolvedValue({ targetOrigin: DATADOG_PARENT, bundle }); + Reflect.set(globalThis, DEV_CLIENT_HOOK, prepareCall); + + const result = postMessageTransport(QUERY_NAME, ARGS); + const message = await posted; + reply(message.requestId, 'ok'); + + await expect(result).resolves.toBe('ok'); + expect(message).toEqual({ ...MESSAGE_WITHOUT_BUNDLE, bundle: BUNDLE }); + expect(postMessage).toHaveBeenCalledWith(message, DATADOG_PARENT); + }); + test('Should post the call without a bundle when reading the hook itself throws', async () => { const { postMessage, posted, reply } = installFakeWindow(); Object.defineProperty(globalThis, DEV_CLIENT_HOOK, { 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 3bb7db8b6..1fc8744a3 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 @@ -7,7 +7,7 @@ import { DEV_CLIENT_HOOK, RUN_QUERY, RUN_QUERY_RESPONSE } from '../../../protocol'; import type { DebugBundleResponse, PreparedCall } from '../../../protocol'; -import { copyByIndex, isDebugBundle, isRecord } from '../../guards'; +import { copyByIndex, copyDebugBundleActions, isDebugBundle, isRecord } from '../../guards'; import { generateRequestId } from '../../request-id'; import type { BackendFunctionTransport } from '../../types'; import { BackendFunctionError } from '../../types'; @@ -46,12 +46,16 @@ function copyBundle(candidate: unknown): DebugBundleResponse | undefined { return undefined; } const ids = 'allowedConnectionIds' in candidate ? candidate.allowedConnectionIds : undefined; - // Checked on a copy, so only the code and connection ids reach postMessage. + // Checked on a copy, so only the code, connection ids and action list reach postMessage. const copy = { code: 'code' in candidate ? candidate.code : undefined, allowedConnectionIds: Array.isArray(ids) ? copyByIndex(ids) : undefined, }; - return isDebugBundle(copy) ? copy : undefined; + if (!isDebugBundle(copy)) { + return undefined; + } + const actions = copyDebugBundleActions(candidate); + return { ...copy, ...(actions && { actions }) }; } // Any page script could install the hook, so its result is checked before anything is posted. diff --git a/packages/plugins/apps/src/backend/protocol.ts b/packages/plugins/apps/src/backend/protocol.ts index 14eb8978a..cdb254fd3 100644 --- a/packages/plugins/apps/src/backend/protocol.ts +++ b/packages/plugins/apps/src/backend/protocol.ts @@ -29,9 +29,19 @@ export const PONG = 'app-builder:pong'; // The `globalThis` key where the dev client the dev server injects installs its call hook. export const DEV_CLIENT_HOOK = 'DD_APPS_PREPARE_CALL'; +export interface DebugBundleAction { + fqn: string; + connectionIds: string[]; +} + export interface DebugBundleResponse { code: string; allowedConnectionIds: string[]; + /** + * A UX hint, not a security boundary. Lists the same direct action-catalog calls as + * `allowedConnectionIds`, never `$.Actions` ones; absent when a call's FQN is unknown. + */ + actions?: DebugBundleAction[]; } // Asks the parent to identify itself: its pong's `event.origin` is set by the browser. diff --git a/packages/plugins/apps/src/vite/action-catalog-fqn-collector.test.ts b/packages/plugins/apps/src/vite/action-catalog-fqn-collector.test.ts new file mode 100644 index 000000000..c3db1796e --- /dev/null +++ b/packages/plugins/apps/src/vite/action-catalog-fqn-collector.test.ts @@ -0,0 +1,107 @@ +// 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 { getMockLogger, mockLogFn } from '@dd/tests/_jest/helpers/mocks'; +import { parseAst } from 'rollup/parseAst'; + +import { createParsedModuleRecord } from '../backend/ast-parsing/module-graph'; +import type { ParsedModuleRecord } from '../backend/ast-parsing/module-graph'; + +import { createActionCatalogFqnCollector } from './action-catalog-fqn-collector'; + +const PACKAGE_MODULE_ID = + '/project/node_modules/@datadog/action-catalog/dist/integrations/http/http.js'; +const PACKAGE_MODULE_CODE = + 'import{executeAction as t}from"../../action-execution";function o(e){return t("com.datadoghq.http.request",e)}export{o as request};'; + +const APP_MODULE_ID = '/project/backend/catalog.js'; + +function createCollector(moduleRecords: ReadonlyMap = new Map()) { + const logger = getMockLogger(); + return createActionCatalogFqnCollector(logger, () => moduleRecords); +} + +function emit( + collector: ReturnType, + moduleInfo: { id: string; code: string | null }, +) { + const hook = collector.plugin.moduleParsed; + if (typeof hook !== 'function') { + throw new Error('Expected "moduleParsed" to be a function hook.'); + } + const parse = jest.fn(parseAst); + Reflect.apply(hook, { parse }, [moduleInfo]); + return parse; +} + +describe('Backend Functions - createActionCatalogFqnCollector', () => { + test('Should record the export fqns of a package module, keyed by its normalized id', () => { + const collector = createCollector(); + + emit(collector, { id: `${PACKAGE_MODULE_ID}?v=1`, code: PACKAGE_MODULE_CODE }); + + const exportFqnsByModule = collector.getExportFqnsByModule(); + const expectedFqns = new Map([['request', 'com.datadoghq.http.request']]); + const expectedExportFqnsByModule = new Map([[PACKAGE_MODULE_ID, expectedFqns]]); + expect(exportFqnsByModule).toEqual(expectedExportFqnsByModule); + }); + + const skippedCases = [ + { + description: 'a virtual module', + moduleInfo: { id: '\0virtual:entry', code: PACKAGE_MODULE_CODE }, + }, + { + description: 'a module without source', + moduleInfo: { id: PACKAGE_MODULE_ID, code: null }, + }, + { + description: 'a module that names no action', + moduleInfo: { id: PACKAGE_MODULE_ID, code: 'export const value = 1;' }, + }, + ]; + + test.each(skippedCases)('Should not parse $description', ({ moduleInfo }) => { + const collector = createCollector(); + + const parse = emit(collector, moduleInfo); + + expect(parse).not.toHaveBeenCalled(); + const exportFqnsByModule = collector.getExportFqnsByModule(); + expect(exportFqnsByModule.size).toBe(0); + }); + + test('Should skip an unparseable package module instead of failing the build, logging its id', () => { + mockLogFn.mockClear(); + const collector = createCollector(); + + expect(() => + emit(collector, { id: PACKAGE_MODULE_ID, code: 'export {{ "com.datadoghq.x.y"' }), + ).not.toThrow(); + const exportFqnsByModule = collector.getExportFqnsByModule(); + expect(exportFqnsByModule.size).toBe(0); + const expectedLogMessage = expect.stringContaining(PACKAGE_MODULE_ID); + expect(mockLogFn).toHaveBeenCalledWith(expectedLogMessage, 'debug'); + }); + + test("Should reuse an app module's collected record instead of parsing it again", () => { + const appModuleCode = + 'function o(e){return run("com.datadoghq.http.request",e)}export{o as request};'; + const program = parseAst(appModuleCode); + const record = createParsedModuleRecord(APP_MODULE_ID, '/project', program, []); + if (!record) { + throw new Error('Expected a record for an app module.'); + } + const moduleRecords = new Map([[APP_MODULE_ID, record]]); + const collector = createCollector(moduleRecords); + + const parse = emit(collector, { id: APP_MODULE_ID, code: appModuleCode }); + + expect(parse).not.toHaveBeenCalled(); + const expectedFqns = new Map([['request', 'com.datadoghq.http.request']]); + const exportFqnsByModule = collector.getExportFqnsByModule(); + const expectedExportFqnsByModule = new Map([[APP_MODULE_ID, expectedFqns]]); + expect(exportFqnsByModule).toEqual(expectedExportFqnsByModule); + }); +}); diff --git a/packages/plugins/apps/src/vite/action-catalog-fqn-collector.ts b/packages/plugins/apps/src/vite/action-catalog-fqn-collector.ts new file mode 100644 index 000000000..6fbb20a06 --- /dev/null +++ b/packages/plugins/apps/src/vite/action-catalog-fqn-collector.ts @@ -0,0 +1,60 @@ +// 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 { Logger } from '@dd/core/types'; +import type { Plugin, Rollup } from 'vite'; + +import { + ACTION_FQN_PREFIX, + collectActionCatalogExportFqns, +} from '../backend/ast-parsing/action-catalog-fqns'; +import type { ParsedModuleRecord } from '../backend/ast-parsing/module-graph'; +import { ensureProgram } from '../backend/ast-parsing/type-guards'; + +import { isViteVirtualModuleId, normalizeViteModuleId } from './backend-module-graph-collector'; + +export interface ActionCatalogFqnCollector { + plugin: Plugin; + getExportFqnsByModule: () => ReadonlyMap>; +} + +// Register after the connection-ID collector's plugin, whose records it reuses instead of re-parsing. +export function createActionCatalogFqnCollector( + log: Logger, + getModuleRecords: () => ReadonlyMap, +): ActionCatalogFqnCollector { + const exportFqnsByModule = new Map>(); + + return { + plugin: { + name: 'dd-action-catalog-fqn-collector', + moduleParsed(moduleInfo: Rollup.ModuleInfo) { + const moduleId = normalizeViteModuleId(moduleInfo.id); + if ( + isViteVirtualModuleId(moduleId) || + typeof moduleInfo.code !== 'string' || + !moduleInfo.code.includes(ACTION_FQN_PREFIX) + ) { + return; + } + try { + let program = getModuleRecords().get(moduleId)?.ast; + if (!program) { + const ast = this.parse(moduleInfo.code); + program = ensureProgram(ast, moduleId); + } + const exportFqns = collectActionCatalogExportFqns(program); + exportFqnsByModule.set(moduleId, exportFqns); + } catch (error) { + // A module without a table only drops the action list, never fails the build. + const reason = error instanceof Error ? error.message : String(error); + log.debug(`Skipped the action FQNs of ${moduleId}: ${reason}`); + } + }, + }, + getExportFqnsByModule() { + return exportFqnsByModule; + }, + }; +} diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index 970db2482..3e1330476 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -113,6 +113,13 @@ const mixedImportsFunc: BackendFunction = { allowedConnectionIds: [], }; +const twoActionsFunc: BackendFunction = { + relativePath: 'twoActions', + name: 'fetchAndNotify', + absolutePath: path.join(FIXTURE_ROOT, 'twoActions.backend.ts'), + allowedConnectionIds: [], +}; + const previewRuntimeContext = { Source: { initiator: { id: 'preview-initiator', orgId: 'preview-org' }, @@ -488,6 +495,46 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(body.code).toContain('conn-1'); }, 30000); + test('Should list the actions a real backend file calls, with their connection IDs, from /__dd/debugBundle as JSON', async () => { + const loadModule = server.ssrLoadModule.bind(server); + const isHostAllowed = createViteHostCheck(server.config); + const logger = getMockLogger(); + const middleware = createDevServerMiddleware( + build, + loadModule, + () => [twoActionsFunc], + async () => [], + { site: 'datadoghq.com' }, + undefined, + mockLongPolling, + FIXTURE_ROOT, + logger, + 'development', + isHostAllowed, + ); + + const functionName = encodeQueryName(twoActionsFunc); + const req = createMockRequest( + DEBUG_BUNDLE_PATH, + { functionName }, + { accept: 'application/json' }, + ); + const res = createMockResponse(); + + const next = jest.fn(); + middleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(200); + const rawBody = res.getBody(); + const body = JSON.parse(rawBody); + expect(body.allowedConnectionIds).toEqual(['conn-1', 'conn-http']); + expect(body.actions).toEqual([ + { fqn: 'com.datadoghq.http.request', connectionIds: ['conn-http'] }, + { fqn: 'com.datadoghq.slack.chat.postMessage', connectionIds: ['conn-1'] }, + ]); + }, 30000); + // A dynamic import sits between two static ones, so resolution must come from the AST // itself rather than node.importedModules's undocumented ordering for mixed imports. test('Should recognize a connectionId-scoped action-catalog call even when a top-level dynamic import sits between two static imports', async () => { diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 38140a358..8d1d2beaf 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -5,13 +5,17 @@ /* global globalThis */ import { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; +import { walkModuleGraph } from '@dd/apps-plugin/backend/ast-parsing/walk-module-graph'; import { createViteHostCheck } from '@dd/apps-plugin/vite/dev-server-request-guard'; import { createDevServerMiddleware, DEBUG_BUNDLE_ROUTE, DEV_SERVER_ROUTES, + EXECUTE_ACTION_VIA_CLOUD_ROUTE, formatRef, + getMissingActionListMessage, getRetryDelay, + NO_ACTION_FQNS_MESSAGE, } from '@dd/apps-plugin/vite/dev-server'; import { withTimeout } from '@dd/apps-plugin/vite/local-execution'; import type { AuthOptionsWithDefaults, RequestOpts } from '@dd/core/types'; @@ -40,6 +44,15 @@ function testDollarActions(): ActionsProxy { return (globalThis as typeof globalThis & { $: { Actions: ActionsProxy } }).$.Actions; } +jest.mock('@dd/apps-plugin/backend/ast-parsing/walk-module-graph', () => { + const actual = jest.requireActual< + typeof import('@dd/apps-plugin/backend/ast-parsing/walk-module-graph') + >('@dd/apps-plugin/backend/ast-parsing/walk-module-graph'); + return { ...actual, walkModuleGraph: jest.fn(actual.walkModuleGraph) }; +}); + +const walkModuleGraphMock = jest.mocked(walkModuleGraph); + const mockViteBuild = jest.fn(); /** Stands in for the real `server.ssrLoadModule` — the local executeAction path doesn't bundle, so tests exercising it configure this directly instead of `mockBuildWithParsedBackend`. */ @@ -636,7 +649,7 @@ describe('Dev Server Middleware', () => { ]; test.each(jsonAcceptCases)( - 'Should return code and no connection IDs as JSON for an Accept header of $description', + 'Should return code, no connection IDs and no actions as JSON for an Accept header of $description', async ({ accept }) => { mockBuildWithParsedBackend('export function main($) {}'); @@ -655,6 +668,7 @@ describe('Dev Server Middleware', () => { expect(body).toEqual({ code: 'export function main($) {}', allowedConnectionIds: [], + actions: [], }); }, ); @@ -702,6 +716,199 @@ describe('Dev Server Middleware', () => { }); }); + test("Should list each action the bundled function's file calls, with its connection IDs, as JSON", async () => { + // A fresh middleware, since an earlier test's warning is only logged once per middleware. + const freshMiddleware = createTestMiddleware(); + const entryCode = ` + import { request } from '@datadog/action-catalog/http/http'; + import * as catalog from '@datadog/action-catalog'; + export function greet() { + request({ connectionId: 'conn-http', inputs: {} }); + return catalog.sendSlackMessage({ inputs: {} }); + } + `; + mockViteBuild.mockImplementation(async (config) => { + await emitModuleParsed(config, mockFunctions[0].absolutePath, entryCode); + await emitModuleParsed( + config, + '@datadog/action-catalog/http/http', + "export function request(e) { return run('com.datadoghq.http.request', e); }", + ); + await emitModuleParsed( + config, + '@datadog/action-catalog', + "export function sendSlackMessage(e) { return run('com.datadoghq.slack.chat.postMessage', e); }", + ); + return mockBuildResult('// bundled'); + }); + + const functionName = encodeQueryName(mockFunctions[0]); + const req = createMockRequest( + DEBUG_BUNDLE_PATH, + { functionName }, + { accept: 'application/json' }, + ); + const res = createMockResponse(); + + const next = jest.fn(); + freshMiddleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(200); + const rawBody = res.getBody(); + const body = JSON.parse(rawBody); + expect(body).toEqual({ + code: '// bundled', + allowedConnectionIds: ['conn-http'], + actions: [ + { fqn: 'com.datadoghq.http.request', connectionIds: ['conn-http'] }, + { fqn: 'com.datadoghq.slack.chat.postMessage', connectionIds: [] }, + ], + }); + expect(mockLogFn).not.toHaveBeenCalledWith(NO_ACTION_FQNS_MESSAGE, 'warn'); + }); + + test('Should walk the action-catalog calls once per JSON debugBundle request', async () => { + const entryCode = ` + import { request } from '@datadog/action-catalog/http/http'; + export function greet() { + return request({ connectionId: 'conn-http', inputs: {} }); + } + `; + mockViteBuild.mockImplementation(async (config) => { + await emitModuleParsed(config, mockFunctions[0].absolutePath, entryCode); + return mockBuildResult('// bundled'); + }); + + const functionName = encodeQueryName(mockFunctions[0]); + const req = createMockRequest( + DEBUG_BUNDLE_PATH, + { functionName }, + { accept: 'application/json' }, + ); + const res = createMockResponse(); + + const next = jest.fn(); + middleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(200); + expect(walkModuleGraphMock).toHaveBeenCalledTimes(1); + }); + + test('Should omit the action list and log why when a call has no known action FQN', async () => { + const entryCode = ` + import request from '@datadog/action-catalog/http/http'; + export function greet() { + return request({ connectionId: 'conn-http', inputs: {} }); + } + `; + mockViteBuild.mockImplementation(async (config) => { + await emitModuleParsed(config, mockFunctions[0].absolutePath, entryCode); + return mockBuildResult('// bundled'); + }); + + const functionName = encodeQueryName(mockFunctions[0]); + const req = createMockRequest( + DEBUG_BUNDLE_PATH, + { functionName }, + { accept: 'application/json' }, + ); + const res = createMockResponse(); + + const next = jest.fn(); + middleware(req, res, next); + await res.done; + + expect(res.statusCode).toBe(200); + const rawBody = res.getBody(); + const body = JSON.parse(rawBody); + expect(body).toEqual({ code: '// bundled', allowedConnectionIds: ['conn-http'] }); + const functionRef = formatRef(mockFunctions[0]); + const omittedMessage = getMissingActionListMessage(functionRef); + expect(mockLogFn).toHaveBeenCalledWith(omittedMessage, 'debug'); + }); + + const nonJsonBundleCases = [ + { description: 'a plain-text debugBundle response', route: DEBUG_BUNDLE_PATH }, + { description: 'an executeActionViaCloud call', route: EXECUTE_ACTION_VIA_CLOUD_ROUTE }, + ]; + + test.each(nonJsonBundleCases)( + 'Should not build the action list for $description', + async ({ route }) => { + const entryCode = ` + import request from '@datadog/action-catalog/http/http'; + export function greet() { + return request({ connectionId: 'conn-http', inputs: {} }); + } + `; + mockViteBuild.mockImplementation(async (config) => { + await emitModuleParsed(config, mockFunctions[0].absolutePath, entryCode); + return mockBuildResult('// bundled'); + }); + nock(DD_API_ORIGIN) + .post('/api/v2/app-builder/queries/preview-async') + .reply(403, 'Forbidden'); + + const functionName = encodeQueryName(mockFunctions[0]); + const req = createMockRequest(route, { functionName, args: [] }); + const res = createMockResponse(); + + const next = jest.fn(); + middleware(req, res, next); + await res.done; + + expect(mockViteBuild).toHaveBeenCalled(); + const functionRef = formatRef(mockFunctions[0]); + const omittedMessage = getMissingActionListMessage(functionRef); + expect(mockLogFn).not.toHaveBeenCalledWith(omittedMessage, 'debug'); + }, + ); + + test('Should warn once when the action-catalog package maps no export to an action FQN', async () => { + const freshMiddleware = createTestMiddleware(); + const entryCode = ` + import { request } from '@datadog/action-catalog/http/http'; + export function greet() { + return request({ connectionId: 'conn-http', inputs: {} }); + } + `; + mockViteBuild.mockImplementation(async (config) => { + await emitModuleParsed(config, mockFunctions[0].absolutePath, entryCode); + await emitModuleParsed( + config, + '@datadog/action-catalog/http/http', + "export const request = defineAction('com.datadoghq.http.request');", + ); + await emitModuleParsed( + config, + '/project/backend/helper.js', + "export function send(e) { return run('com.datadoghq.http.request', e); }", + ); + return mockBuildResult('// bundled'); + }); + + const functionName = encodeQueryName(mockFunctions[0]); + for (let attempt = 0; attempt < 2; attempt++) { + const req = createMockRequest( + DEBUG_BUNDLE_PATH, + { functionName }, + { accept: 'application/json' }, + ); + const res = createMockResponse(); + const next = jest.fn(); + freshMiddleware(req, res, next); + await res.done; + expect(res.statusCode).toBe(200); + } + + const warnings = mockLogFn.mock.calls.filter( + ([text, level]) => level === 'warn' && text === NO_ACTION_FQNS_MESSAGE, + ); + expect(warnings).toHaveLength(1); + }); + test('Should return a JSON 404 for an unknown query name when JSON is requested', async () => { const req = createMockRequest( DEBUG_BUNDLE_PATH, diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index 87ab38176..fdfdb0fd8 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -12,9 +12,16 @@ import type { build } from 'vite'; import { AUTH_GUIDANCE } from '../auth'; import type { DoAuthenticatedRequest } from '../auth'; +import { ACTION_CATALOG_PACKAGE } from '../backend/ast-parsing/action-catalog-imports'; +import { + collectActionCatalogCalls, + getConnectionIds, +} from '../backend/ast-parsing/extract-connection-ids-from-module-graph'; +import { listActionCalls } from '../backend/ast-parsing/list-action-calls'; import { encodeQueryName } from '../backend/encodeQueryName'; import { DEBUG_BUNDLE_PATH } from '../backend/protocol'; import type { + DebugBundleAction, DebugBundleResponse, ExecuteActionRequest, ExecuteActionResponse, @@ -24,6 +31,7 @@ import { generateDevVirtualEntryContent } from '../backend/virtual-entry'; import { DEV_VERIFY_MODE } from '../constants'; import type { LongPollingOptions } from '../types'; +import { createActionCatalogFqnCollector } from './action-catalog-fqn-collector'; import { createBackendConnectionIdCollector } from './backend-connection-id-collector'; import { createBackendStaticChecksPlugin } from './backend-static-checks-plugin'; import { getBaseBackendBuildConfig } from './build-config'; @@ -36,6 +44,7 @@ import { getMaxRetryDelayMs } from './retry-delay'; interface BundleResult { func: BackendFunction; code: string; + listActions: () => DebugBundleAction[] | undefined; } type BundleFn = (func: BackendFunction) => Promise; @@ -46,6 +55,11 @@ export const DEBUG_BUNDLE_ROUTE = DEBUG_BUNDLE_PATH; export const EXECUTE_ACTION_ROUTE = '/__dd/executeAction'; export const EXECUTE_ACTION_VIA_CLOUD_ROUTE = '/__dd/executeActionViaCloud'; +export const NO_ACTION_FQNS_MESSAGE = `Found no action FQNs in any bundled package, so debugBundle responses omit the action list; ${ACTION_CATALOG_PACKAGE}'s build output may have changed shape.`; + +export const getMissingActionListMessage = (displayName: string) => + `Bundled "${displayName}" without an action list: a call has no known action FQN.`; + export const DEV_SERVER_ROUTES = [ DEBUG_BUNDLE_ROUTE, EXECUTE_ACTION_ROUTE, @@ -143,6 +157,7 @@ async function bundleBackendFunction( func: BackendFunction, projectRoot: string, log: Logger, + warnNoActionFqns: () => void, ): Promise { const displayName = formatRef(func); const virtualId = `${DEV_VIRTUAL_PREFIX}${displayName}`; @@ -155,6 +170,10 @@ async function bundleBackendFunction( func.absolutePath, projectRoot, ); + const actionCatalogFqnCollector = createActionCatalogFqnCollector( + log, + connectionIdCollector.getModuleRecords, + ); log.debug(`Bundling backend function "${displayName}" from ${func.absolutePath}`); @@ -166,6 +185,7 @@ async function bundleBackendFunction( const baseConfig = getBaseBackendBuildConfig(projectRoot, { [virtualId]: virtualContent }, [ connectionIdCollector.plugin, staticChecksPlugin, + actionCatalogFqnCollector.plugin, ]); // Dev: build a single function in-memory per request so we can send the @@ -192,14 +212,31 @@ async function bundleBackendFunction( } const code = output.output[0].type === 'chunk' ? output.output[0].code : ''; + const moduleRecords = connectionIdCollector.getModuleRecords(); + const calls = collectActionCatalogCalls(func.absolutePath, moduleRecords, projectRoot); const enrichedFunc = { ...func, - allowedConnectionIds: connectionIdCollector.getAllowedConnectionIds(), + allowedConnectionIds: getConnectionIds(calls), + }; + const listActions = () => { + const exportFqnsByModule = actionCatalogFqnCollector.getExportFqnsByModule(); + const hasPackageExportFqns = [...exportFqnsByModule].some( + ([moduleId, fqns]) => fqns.size > 0 && !moduleRecords.has(moduleId), + ); + if (calls.length > 0 && !hasPackageExportFqns) { + warnNoActionFqns(); + } + const actions = listActionCalls(calls, exportFqnsByModule); + if (!actions) { + const missingListMessage = getMissingActionListMessage(displayName); + log.debug(missingListMessage); + } + return actions; }; log.debug(`Bundled "${displayName}" (${code.length} bytes)`); - return { func: enrichedFunc, code }; + return { func: enrichedFunc, code, listActions }; } /** @@ -567,7 +604,7 @@ async function validateAndBundle( req: IncomingMessage, functionsByName: Map, bundle: BundleFn, -): Promise<{ func: BackendFunction; code: string; args: unknown[] }> { +): Promise { const { func, args } = await parseAndLookupFunction(req, functionsByName); const bundled = await bundle(func); return { ...bundled, args }; @@ -586,7 +623,7 @@ function acceptsJson(req: IncomingMessage): boolean { /** * Handle POST /__dd/debugBundle — returns the bundled script, as plain text by default or as - * `{ code, allowedConnectionIds }` when the Accept header lists `application/json` without q=0. + * `{ code, allowedConnectionIds, actions? }` when the Accept header lists `application/json` without q=0. */ async function handleDebugBundle( req: IncomingMessage, @@ -595,12 +632,14 @@ async function handleDebugBundle( bundle: BundleFn, ): Promise { try { - const { func, code } = await validateAndBundle(req, functionsByName, bundle); + const { func, code, listActions } = await validateAndBundle(req, functionsByName, bundle); if (acceptsJson(req)) { + const actions = listActions(); const body: DebugBundleResponse = { code, allowedConnectionIds: func.allowedConnectionIds, + ...(actions && { actions }), }; sendJson(res, 200, body); return; @@ -753,8 +792,15 @@ export function createDevServerMiddleware( mode: string, isHostAllowed: HostCheck, ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { + let hasWarnedNoActionFqns = false; + const warnNoActionFqns = () => { + if (!hasWarnedNoActionFqns) { + hasWarnedNoActionFqns = true; + log.warn(NO_ACTION_FQNS_MESSAGE); + } + }; const bundle = (func: BackendFunction) => - bundleBackendFunction(viteBuild, func, projectRoot, log); + bundleBackendFunction(viteBuild, func, projectRoot, log, warnNoActionFqns); const isDevVerifyMode = mode === DEV_VERIFY_MODE; const initialFunctions = getBackendFunctions(); diff --git a/packages/tests/src/_jest/fixtures/action_catalog_project/http/http.js b/packages/tests/src/_jest/fixtures/action_catalog_project/http/http.js new file mode 100644 index 000000000..fa59167b4 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/action_catalog_project/http/http.js @@ -0,0 +1,9 @@ +// 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 { getExecuteActionImplementation } from '../action-execution.js'; + +export function request(request) { + return getExecuteActionImplementation()('com.datadoghq.http.request', request); +} diff --git a/packages/tests/src/_jest/fixtures/action_catalog_project/package.json b/packages/tests/src/_jest/fixtures/action_catalog_project/package.json index 678e69045..9b192c82f 100644 --- a/packages/tests/src/_jest/fixtures/action_catalog_project/package.json +++ b/packages/tests/src/_jest/fixtures/action_catalog_project/package.json @@ -5,10 +5,11 @@ "license": "MIT", "author": "Datadog", "type": "module", - "description": "Minimal local fixture standing in for the real @datadog/action-catalog package — only the pieces dev-server.integration.test.ts's connection-ID coverage actually exercises.", + "description": "Minimal local fixture standing in for the real @datadog/action-catalog package — only the pieces dev-server.integration.test.ts's connection-ID and action-list coverage actually exercises.", "main": "index.js", "exports": { ".": "./index.js", - "./action-execution": "./action-execution.js" + "./action-execution": "./action-execution.js", + "./http/http": "./http/http.js" } } diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/twoActions.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/twoActions.backend.ts new file mode 100644 index 000000000..0d753b528 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/twoActions.backend.ts @@ -0,0 +1,14 @@ +// 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 { sendSlackMessage } from '@datadog/action-catalog'; +import * as http from '@datadog/action-catalog/http/http'; + +export async function fetchAndNotify() { + await http.request({ + inputs: { url: 'https://example.com', verb: 'GET' }, + connectionId: 'conn-http', + }); + return sendSlackMessage({ inputs: { text: 'hi' }, connectionId: 'conn-1' }); +}