From eba15b2476d9d7160c1dda4027f4f685397df508 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Fri, 23 Oct 2026 11:55:57 -0400 Subject: [PATCH] feat(apps): list each backend function's action calls in the debugBundle response POST /__dd/debugBundle's JSON response gains an optional actions list: one { fqn, connectionIds } entry per action statically called through @datadog/action-catalog imports in the function's file and the modules it imports, the same calls allowedConnectionIds comes from, ordered by fqn. The dev server builds the list only for the JSON response, from the connection-ID collector's module records and an FQN collector that reads each action's FQN from the catalog module the bundle loaded, since the import path alone doesn't name the action. Each bundle walks the calls once, for both allowedConnectionIds and the list. The FQN collector reuses an app module's parsed record and logs a module it can't read at debug level. The list is omitted, with a debug log, when any call's FQN is unknown. When a function calls actions but no bundled package module maps an export to an action FQN, the dev server warns once per middleware, since the action-catalog build output may have changed shape. The dev client and the frame transport forward a well-formed list with the bundle, copied by index; a malformed list is dropped and the bundle is still sent. Co-Authored-By: Claude Opus 5.5 --- .../ast-parsing/action-catalog-fqns.test.ts | 90 ++++++++ .../ast-parsing/action-catalog-fqns.ts | 94 ++++++++ .../ast-parsing/action-catalog-imports.ts | 2 +- ...xtract-connection-ids-from-module-graph.ts | 37 +++- .../ast-parsing/list-action-calls.test.ts | 146 ++++++++++++ .../backend/ast-parsing/list-action-calls.ts | 67 ++++++ .../client/dev-client/dev-client.test.ts | 38 ++++ .../backend/client/dev-client/dev-client.ts | 6 +- .../plugins/apps/src/backend/client/guards.ts | 43 +++- .../post-message-transport.test.ts | 68 ++++++ .../post-message-transport.ts | 10 +- packages/plugins/apps/src/backend/protocol.ts | 10 + .../vite/action-catalog-fqn-collector.test.ts | 107 +++++++++ .../src/vite/action-catalog-fqn-collector.ts | 60 +++++ .../src/vite/dev-server.integration.test.ts | 48 ++++ .../plugins/apps/src/vite/dev-server.test.ts | 209 +++++++++++++++++- packages/plugins/apps/src/vite/dev-server.ts | 65 +++++- .../action_catalog_project/http/http.js | 9 + .../action_catalog_project/package.json | 5 +- .../twoActions.backend.ts | 14 ++ 20 files changed, 1104 insertions(+), 24 deletions(-) create mode 100644 packages/plugins/apps/src/backend/ast-parsing/action-catalog-fqns.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/action-catalog-fqns.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/list-action-calls.test.ts create mode 100644 packages/plugins/apps/src/backend/ast-parsing/list-action-calls.ts create mode 100644 packages/plugins/apps/src/vite/action-catalog-fqn-collector.test.ts create mode 100644 packages/plugins/apps/src/vite/action-catalog-fqn-collector.ts create mode 100644 packages/tests/src/_jest/fixtures/action_catalog_project/http/http.js create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/twoActions.backend.ts 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 dc5334c01..4efd408bf 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 71962b61e..e5d14d50b 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -121,6 +121,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' }, @@ -502,6 +509,47 @@ 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, + defaultServerConditions, + ); + + 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 27a2bfaa9..f5eef3340 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -5,14 +5,18 @@ /* global globalThis */ import { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; +import { walkModuleGraph } from '@dd/apps-plugin/backend/ast-parsing/walk-module-graph'; import { buildBackendFunctions } from '@dd/apps-plugin/vite/build-backend-functions'; 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, oversizedBundleMessage, } from '@dd/apps-plugin/vite/dev-server'; import { withTimeout } from '@dd/apps-plugin/vite/local-execution'; @@ -44,6 +48,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`. */ @@ -642,7 +655,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($) {}'); @@ -661,6 +674,7 @@ describe('Dev Server Middleware', () => { expect(body).toEqual({ code: 'export function main($) {}', allowedConnectionIds: [], + actions: [], }); }, ); @@ -708,6 +722,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 81bb70d25..02ac8d273 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, withProductionSettings } 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, @@ -144,6 +158,7 @@ async function bundleBackendFunction( func: BackendFunction, projectRoot: string, log: Logger, + warnNoActionFqns: () => void, ): Promise { const displayName = formatRef(func); const virtualId = `${DEV_VIRTUAL_PREFIX}${displayName}`; @@ -156,6 +171,10 @@ async function bundleBackendFunction( func.absolutePath, projectRoot, ); + const actionCatalogFqnCollector = createActionCatalogFqnCollector( + log, + connectionIdCollector.getModuleRecords, + ); log.debug(`Bundling backend function "${displayName}" from ${func.absolutePath}`); @@ -167,6 +186,7 @@ async function bundleBackendFunction( const devBaseConfig = getBaseBackendBuildConfig(projectRoot, { [virtualId]: virtualContent }, [ connectionIdCollector.plugin, staticChecksPlugin, + actionCatalogFqnCollector.plugin, ]); const baseConfig = withProductionSettings(devBaseConfig, serverConditions); @@ -194,14 +214,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 }; } /** @@ -609,7 +646,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 }; @@ -628,7 +665,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, @@ -637,12 +674,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; @@ -806,8 +845,22 @@ export function createDevServerMiddleware( isHostAllowed: HostCheck, serverConditions: readonly string[] | undefined, ): (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, serverConditions, func, projectRoot, log); + bundleBackendFunction( + viteBuild, + serverConditions, + func, + projectRoot, + log, + warnNoActionFqns, + ); const isDevVerifyMode = mode === DEV_VERIFY_MODE; const oversizedFunctionsWarned = new Set(); 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 dc7e226a7..41e3a9c9b 100644 --- a/packages/tests/src/_jest/fixtures/action_catalog_project/package.json +++ b/packages/tests/src/_jest/fixtures/action_catalog_project/package.json @@ -5,11 +5,12 @@ "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", - "./dd/metrics": "./dd/metrics.js" + "./dd/metrics": "./dd/metrics.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' }); +}