From 7e4dfa6c12b4ee995d73f4e309d743d64f48ee04 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Fri, 30 Jan 2026 15:34:12 -0500 Subject: [PATCH 1/2] [APPS] Added in vite dev server middleware for local function developement --- packages/plugins/apps/src/dev-server.ts | 267 ++++++++++++++++++ packages/plugins/apps/src/index.ts | 16 ++ .../published/esbuild-plugin/package.json | 14 +- packages/published/rollup-plugin/package.json | 18 +- packages/published/rspack-plugin/package.json | 14 +- packages/published/vite-plugin/package.json | 14 +- .../published/webpack-plugin/package.json | 14 +- 7 files changed, 340 insertions(+), 17 deletions(-) create mode 100644 packages/plugins/apps/src/dev-server.ts diff --git a/packages/plugins/apps/src/dev-server.ts b/packages/plugins/apps/src/dev-server.ts new file mode 100644 index 00000000..f274ecde --- /dev/null +++ b/packages/plugins/apps/src/dev-server.ts @@ -0,0 +1,267 @@ +import { readFile } from 'fs/promises'; +// 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 { IncomingMessage, ServerResponse } from 'http'; +import path from 'path'; + +interface ExecuteActionRequest { + functionName: string; + args?: any[]; +} + +interface ExecuteActionResponse { + success: boolean; + result?: any; + error?: string; +} + +/** + * Parse JSON body from incoming request stream + */ +async function parseRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', () => { + try { + resolve(JSON.parse(body)); + } catch (error) { + reject(new Error('Invalid JSON body')); + } + }); + req.on('error', reject); + }); +} + +/** + * Load backend function file from the project + * Tries common file extensions: .ts, .js, .tsx, .jsx + */ +async function loadBackendFunction(functionName: string, projectRoot: string): Promise { + const extensions = ['.ts', '.js', '.tsx', '.jsx']; + const backendDir = path.join(projectRoot, 'backend'); + + for (const ext of extensions) { + const filePath = path.join(backendDir, `${functionName}${ext}`); + try { + const content = await readFile(filePath, 'utf-8'); + console.log(`Loaded backend function from: ${filePath}`); + return content; + } catch (error: any) { + // File doesn't exist, try next extension + if (error.code !== 'ENOENT') { + // Some other error occurred, rethrow + throw error; + } + } + } + + throw new Error( + `Backend function "${functionName}" not found. Looked in: ${backendDir}/${functionName}{${extensions.join(',')}}`, + ); +} + +/** + * Transform backend function code into Datadog App Builder script body format + * Wraps the function in a main() export that accepts the $ context + */ +function transformToScriptBody(functionCode: string, functionName: string, args: any[]): string { + // Remove any existing export keywords from the original function + const cleanedCode = functionCode.replace(/^export\s+(async\s+)?function/, 'async function'); + + // Build the script body that wraps the original function + const scriptBody = `import * as _ from 'lodash'; +// Use \`_\` to access Lodash. See https://lodash.com/ for reference. + +${cleanedCode} + +/** @param {import('./context.types').Context} $ */ +export async function main($) { + // Execute the backend function with provided arguments + const result = await ${functionName}(...${JSON.stringify(args)}); + return result; +}`; + + return scriptBody; +} + +/** + * Poll for action execution result + */ +async function pollActionExecution( + workflowId: string, + executionId: string, + apiKey: string, + appKey: string, + site: string, +): Promise { + const endpoint = `https://${site}/api/v2/workflows/${workflowId}/single_action_runs/${executionId}`; + const maxAttempts = 30; // 30 attempts + const pollInterval = 1000; // 1 second + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + console.log(`Polling attempt ${attempt + 1}/${maxAttempts}...`); + + const response = await fetch(endpoint, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'DD-API-KEY': apiKey, + 'DD-APPLICATION-KEY': appKey, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Datadog API error (${response.status}): ${errorText}`); + } + + const result = await response.json(); + const state = result.data?.attributes?.state; + + console.log(`Execution state: ${state}`); + + if (state === 'SUCCEEDED') { + return result.data.attributes.outputs; + } else if (state === 'FAILED') { + throw new Error(`Action execution failed: ${JSON.stringify(result.data.attributes)}`); + } + + // Still pending, wait before next poll + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } + + throw new Error('Action execution timed out'); +} + +/** + * Execute script via Datadog single_action_runs API + */ +async function executeScriptViaDatadog( + scriptBody: string, + apiKey: string, + appKey: string, + site: string, +): Promise { + // Hardcoded workflow ID for development + const workflowId = '380e7df1-729c-420c-b15e-a3b8e6347d49'; + const endpoint = `https://${site}/api/v2/workflows/${workflowId}/single_action_runs`; + + const requestBody = { + data: { + type: 'single_action_runs', + attributes: { + actionId: 'com.datadoghq.datatransformation.jsFunctionWithActions', + inputs: { + script: scriptBody, + context: {}, + }, + }, + }, + }; + + console.log(`Calling Datadog API: ${endpoint}`); + + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'DD-API-KEY': apiKey, + 'DD-APPLICATION-KEY': appKey, + }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Datadog API error (${response.status}): ${errorText}`); + } + + const initialResult = await response.json(); + const executionId = initialResult.data?.id; + + if (!executionId) { + throw new Error('No execution ID returned from Datadog API'); + } + + console.log(`Action started with ID: ${executionId}`); + + // Poll for result + const outputs = await pollActionExecution(workflowId, executionId, apiKey, appKey, site); + + return outputs; +} + +interface AuthConfig { + apiKey: string; + appKey: string; + site: string; +} + +/** + * Handle /__dd/executeAction requests + */ +export async function handleExecuteAction( + req: IncomingMessage, + res: ServerResponse, + projectRoot: string, + auth: AuthConfig, +): Promise { + try { + const { functionName, args = [] } = await parseRequestBody(req); + + if (!functionName || typeof functionName !== 'string') { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + success: false, + error: 'Missing or invalid functionName', + } as ExecuteActionResponse), + ); + return; + } + + console.log(`Executing action: ${functionName} with args:`, args); + + // Load backend function file + const functionCode = await loadBackendFunction(functionName, projectRoot); + console.log(`Loaded function code (${functionCode.length} bytes)`); + + // Transform to script body + const scriptBody = transformToScriptBody(functionCode, functionName, args); + console.log(`Transformed to script body (${scriptBody.length} bytes)`); + + // Execute via Datadog API + const apiResult = await executeScriptViaDatadog( + scriptBody, + auth.apiKey, + auth.appKey, + auth.site, + ); + console.log('Datadog API response:', apiResult); + + // Return the result from Datadog + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + success: true, + result: apiResult, + } as ExecuteActionResponse), + ); + } catch (error: any) { + console.error('Error handling executeAction:', error); + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + success: false, + error: error.message || 'Internal server error', + } as ExecuteActionResponse), + ); + } +} diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 3fc9807e..06a0fcea 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -10,6 +10,7 @@ import path from 'path'; import { createArchive } from './archive'; import { collectAssets } from './assets'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; +import { handleExecuteAction } from './dev-server'; import { resolveIdentifier } from './identifier'; import type { AppsOptions } from './types'; import { uploadArchive } from './upload'; @@ -125,6 +126,21 @@ Either: // Upload all the assets at the end of the build. await handleUpload(); }, + vite: { + configureServer(server) { + server.middlewares.use((req, res, next) => { + if (req.url === '/__dd/executeAction' && req.method === 'POST') { + handleExecuteAction(req, res, context.buildRoot, { + apiKey: context.auth.apiKey, + appKey: context.auth.appKey, + site: context.auth.site, + }); + return; + } + next(); + }); + }, + }, }, ]; }; diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index bc601479..4195a96a 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -22,9 +22,12 @@ "module": "./dist/src/index.mjs", "types": "./dist/src/index.d.ts", "exports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } }, "publishConfig": { "access": "public", @@ -84,5 +87,10 @@ }, "peerDependencies": { "esbuild": ">=0.x" + }, + "previousExports": { + "./dist/src": "./dist/src/index.js", + "./dist/src/*": "./dist/src/*", + ".": "./src/index.ts" } } diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index 561ba2dd..479f5aef 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -22,11 +22,12 @@ "module": "./dist/src/index.mjs", "types": "./dist/src/index.d.ts", "exports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - "./dist-basic/src": "./dist-basic/src/index.js", - "./dist-basic/src/*": "./dist-basic/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } }, "publishConfig": { "access": "public", @@ -87,5 +88,12 @@ }, "peerDependencies": { "rollup": ">= 3.x < 5.x" + }, + "previousExports": { + "./dist/src": "./dist/src/index.js", + "./dist/src/*": "./dist/src/*", + "./dist-basic/src": "./dist-basic/src/index.js", + "./dist-basic/src/*": "./dist-basic/src/*", + ".": "./src/index.ts" } } diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index 82f567a3..b558a99d 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -22,9 +22,12 @@ "module": "./dist/src/index.mjs", "types": "./dist/src/index.d.ts", "exports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } }, "publishConfig": { "access": "public", @@ -84,5 +87,10 @@ }, "peerDependencies": { "@rspack/core": "1.x" + }, + "previousExports": { + "./dist/src": "./dist/src/index.js", + "./dist/src/*": "./dist/src/*", + ".": "./src/index.ts" } } diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index bc5eb7ad..b35131ca 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -22,9 +22,12 @@ "module": "./dist/src/index.mjs", "types": "./dist/src/index.d.ts", "exports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } }, "publishConfig": { "access": "public", @@ -84,5 +87,10 @@ }, "peerDependencies": { "vite": ">= 5.x <= 7.x" + }, + "previousExports": { + "./dist/src": "./dist/src/index.js", + "./dist/src/*": "./dist/src/*", + ".": "./src/index.ts" } } diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index af8aa936..5688c2d6 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -22,9 +22,12 @@ "module": "./dist/src/index.mjs", "types": "./dist/src/index.d.ts", "exports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } }, "publishConfig": { "access": "public", @@ -84,5 +87,10 @@ }, "peerDependencies": { "webpack": ">= 5.x < 6.x" + }, + "previousExports": { + "./dist/src": "./dist/src/index.js", + "./dist/src/*": "./dist/src/*", + ".": "./src/index.ts" } } From dbcf20e4914b68ec186839ab6ecf53146d5537a1 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Thu, 5 Mar 2026 14:41:50 -0500 Subject: [PATCH 2/2] [APPS][WIP] Apps dev server for previewing backend functions locally --- packages/plugins/apps/package.json | 3 +- .../plugins/apps/src/backend-functions.ts | 302 ++++++++++++++++++ packages/plugins/apps/src/dev-server.ts | 230 +++++++++++-- packages/plugins/apps/src/index.ts | 63 +++- packages/plugins/apps/src/types.ts | 6 +- packages/plugins/apps/src/upload.ts | 11 +- packages/plugins/apps/src/validate.ts | 1 + .../published/esbuild-plugin/package.json | 9 +- packages/published/rollup-plugin/package.json | 11 +- packages/published/rspack-plugin/package.json | 9 +- packages/published/vite-plugin/package.json | 11 +- .../published/webpack-plugin/package.json | 9 +- 12 files changed, 604 insertions(+), 61 deletions(-) create mode 100644 packages/plugins/apps/src/backend-functions.ts diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 46ac7889..81d08c75 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -26,7 +26,8 @@ "chalk": "2.3.1", "glob": "11.1.0", "jszip": "3.10.1", - "pretty-bytes": "5.6.0" + "pretty-bytes": "5.6.0", + "vite": "^6.0.0" }, "devDependencies": { "typescript": "5.4.3" diff --git a/packages/plugins/apps/src/backend-functions.ts b/packages/plugins/apps/src/backend-functions.ts new file mode 100644 index 00000000..7e7fc319 --- /dev/null +++ b/packages/plugins/apps/src/backend-functions.ts @@ -0,0 +1,302 @@ +// 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 { randomUUID } from 'crypto'; +import * as esbuild from 'esbuild'; +import { mkdir, readdir, readFile, rm, stat } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +export interface BackendFunction { + name: string; + entryPath: string; +} + +interface BackendFunctionQuery { + id: string; + type: string; + name: string; + properties: { + spec: { + fqn: string; + inputs: { + script: string; + }; + }; + }; +} + +interface AuthConfig { + apiKey: string; + appKey: string; + site: string; +} + +const EXTENSIONS = ['.ts', '.js', '.tsx', '.jsx']; +const JS_FUNCTION_WITH_ACTIONS_FQN = 'com.datadoghq.datatransformation.jsFunctionWithActions'; +const NODE_EXTERNALS = [ + 'fs', + 'path', + 'os', + 'http', + 'https', + 'crypto', + 'stream', + 'buffer', + 'util', + 'events', + 'url', + 'querystring', +]; + +/** + * Discover backend functions in the backend directory. + * Supports two patterns: + * - Single file module: backend/functionName.{ts,js,tsx,jsx} + * - Directory module: backend/functionName/index.{ts,js,tsx,jsx} + */ +export async function discoverBackendFunctions( + backendDir: string, + log: Logger, +): Promise { + let entries: string[]; + try { + entries = await readdir(backendDir); + } catch (error: any) { + if (error.code === 'ENOENT') { + log.debug(`No backend directory found at ${backendDir}`); + return []; + } + throw error; + } + + const functions: BackendFunction[] = []; + + for (const entry of entries) { + const entryPath = path.join(backendDir, entry); + const entryStat = await stat(entryPath); + + if (entryStat.isDirectory()) { + // Directory module: backend/functionName/index.{ext} + for (const ext of EXTENSIONS) { + const indexPath = path.join(entryPath, `index${ext}`); + try { + await stat(indexPath); + functions.push({ name: entry, entryPath: indexPath }); + break; + } catch { + // Try next extension + } + } + } else if (entryStat.isFile()) { + // Single file module: backend/functionName.{ext} + const ext = path.extname(entry); + if (EXTENSIONS.includes(ext)) { + const name = path.basename(entry, ext); + functions.push({ name, entryPath }); + } + } + } + + log.debug( + `Discovered ${functions.length} backend function(s): ${functions.map((f) => f.name).join(', ')}`, + ); + return functions; +} + +/** + * Bundle a backend function using esbuild. + * Same approach as dev-server.ts bundleBackendFunction but without vite server dependency. + */ +async function bundleFunction( + func: BackendFunction, + projectRoot: string, + log: Logger, +): Promise { + const tempDir = path.join(tmpdir(), `dd-apps-backend-bundle-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); + + const bundlePath = path.join(tempDir, 'bundle.js'); + + try { + await esbuild.build({ + stdin: { + contents: [ + `export * from ${JSON.stringify(func.entryPath)};`, + `export { setExecuteActionImplementation } from '@datadog/action-catalog/action-execution';`, + ].join('\n'), + resolveDir: projectRoot, + loader: 'ts', + }, + bundle: true, + format: 'esm', + platform: 'node', + target: 'esnext', + outfile: bundlePath, + absWorkingDir: projectRoot, + conditions: ['node', 'import'], + mainFields: ['module', 'main'], + minify: false, + sourcemap: false, + external: NODE_EXTERNALS, + }); + + const bundledCode = await readFile(bundlePath, 'utf-8'); + log.debug(`Bundled backend function "${func.name}" (${bundledCode.length} bytes)`); + return bundledCode; + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +} + +/** + * Transform bundled code into the Action Platform script format. + * Per the RFC, the script is wrapped in a main($) entry point with globalThis.$ = $. + * Args are passed via App Builder's template expression system (backendFunctionRequest). + */ +function transformToProductionScript(bundledCode: string, functionName: string): string { + let cleanedCode = bundledCode; + + // Remove export default statements + cleanedCode = cleanedCode.replace(/export\s+default\s+/g, ''); + // Convert named exports to regular declarations + cleanedCode = cleanedCode.replace(/export\s+(async\s+)?function\s+/g, '$1function '); + cleanedCode = cleanedCode.replace(/export\s+(const|let|var)\s+/g, '$1 '); + + // The backendFunctionRequest template param is resolved at query execution time + // by the executeBackendFunction client via the template_params mechanism. + const scriptBody = `${cleanedCode} + +/** @param {import('./context.types').Context} $ */ +export async function main($) { + globalThis.$ = $; + + // Register the $.Actions-based implementation for executeAction + setExecuteActionImplementation(async (actionId, request) => { + const actionPath = actionId.replace(/^com\\.datadoghq\\./, ''); + const pathParts = actionPath.split('.'); + let actionFn = $.Actions; + for (const part of pathParts) { + if (!actionFn) throw new Error('Action not found: ' + actionId); + actionFn = actionFn[part]; + } + if (typeof actionFn !== 'function') throw new Error('Action is not a function: ' + actionId); + return actionFn(request); + }); + + const result = await ${functionName}(); + return result; +}`; + + return scriptBody; +} + +/** + * Build the ActionQuery objects for each backend function. + */ +function buildQueries(functions: { name: string; script: string }[]): BackendFunctionQuery[] { + return functions.map((func) => ({ + id: randomUUID(), + type: 'action', + name: func.name, + properties: { + spec: { + fqn: JS_FUNCTION_WITH_ACTIONS_FQN, + inputs: { + script: func.script, + }, + }, + }, + })); +} + +/** + * Call the Update App endpoint to set backend function queries on the app definition. + * PATCH /api/v2/app-builder/apps/{app_builder_id} + */ +async function updateApp( + appBuilderId: string, + queries: BackendFunctionQuery[], + auth: AuthConfig, + log: Logger, +): Promise { + const endpoint = `https://api.${auth.site}/api/v2/app-builder/apps/${appBuilderId}`; + + const body = { + data: { + type: 'appDefinitions', + attributes: { + queries, + }, + }, + }; + + log.debug(`Updating app ${appBuilderId} with ${queries.length} backend function query(ies)`); + + const response = await fetch(endpoint, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'DD-API-KEY': auth.apiKey, + 'DD-APPLICATION-KEY': auth.appKey, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `Failed to update app with backend functions (${response.status}): ${errorText}`, + ); + } + + log.debug(`Successfully updated app ${appBuilderId} with backend function queries`); +} + +/** + * Discover, bundle, transform, and publish backend functions to the app definition. + * Called after a successful app upload to emulate backend function support. + */ +export async function publishBackendFunctions( + projectRoot: string, + backendDir: string, + appBuilderId: string, + auth: AuthConfig, + log: Logger, +): Promise<{ errors: Error[]; warnings: string[] }> { + const errors: Error[] = []; + const warnings: string[] = []; + + try { + const absoluteBackendDir = path.resolve(projectRoot, backendDir); + const functions = await discoverBackendFunctions(absoluteBackendDir, log); + + if (functions.length === 0) { + log.debug('No backend functions found, skipping update.'); + return { errors, warnings }; + } + + // Bundle and transform each function + const transformedFunctions: { name: string; script: string }[] = []; + for (const func of functions) { + const bundledCode = await bundleFunction(func, projectRoot, log); + const script = transformToProductionScript(bundledCode, func.name); + transformedFunctions.push({ name: func.name, script }); + } + + // Build queries and update the app + const queries = buildQueries(transformedFunctions); + await updateApp(appBuilderId, queries, auth, log); + + log.info( + `Published ${transformedFunctions.length} backend function(s): ${transformedFunctions.map((f) => f.name).join(', ')}`, + ); + } catch (error: unknown) { + const err = error instanceof Error ? error : new Error(String(error)); + errors.push(err); + } + + return { errors, warnings }; +} diff --git a/packages/plugins/apps/src/dev-server.ts b/packages/plugins/apps/src/dev-server.ts index f274ecde..fdcd948d 100644 --- a/packages/plugins/apps/src/dev-server.ts +++ b/packages/plugins/apps/src/dev-server.ts @@ -1,9 +1,14 @@ -import { readFile } from 'fs/promises'; // Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. +/* eslint-disable no-await-in-loop */ +import type { Logger } from '@dd/core/types'; +import * as esbuild from 'esbuild'; +import { mkdir, readFile, rm } from 'fs/promises'; import type { IncomingMessage, ServerResponse } from 'http'; +import { tmpdir } from 'os'; import path from 'path'; +import type { ViteDevServer } from 'vite'; interface ExecuteActionRequest { functionName: string; @@ -37,42 +42,138 @@ async function parseRequestBody(req: IncomingMessage): Promise { +async function findBackendFunctionPath(functionName: string, projectRoot: string): Promise { const extensions = ['.ts', '.js', '.tsx', '.jsx']; const backendDir = path.join(projectRoot, 'backend'); + const searchPaths: string[] = []; + // Try directory module pattern first: backend/functionName/index.{ext} + for (const ext of extensions) { + const dirPath = path.join(backendDir, functionName, `index${ext}`); + searchPaths.push(dirPath); + try { + await readFile(dirPath, 'utf-8'); + return dirPath; + } catch (error: any) { + if (error.code !== 'ENOENT') { + throw error; + } + } + } + + // Try single file module pattern: backend/functionName.{ext} for (const ext of extensions) { const filePath = path.join(backendDir, `${functionName}${ext}`); + searchPaths.push(filePath); try { - const content = await readFile(filePath, 'utf-8'); - console.log(`Loaded backend function from: ${filePath}`); - return content; + await readFile(filePath, 'utf-8'); + return filePath; } catch (error: any) { - // File doesn't exist, try next extension if (error.code !== 'ENOENT') { - // Some other error occurred, rethrow throw error; } } } throw new Error( - `Backend function "${functionName}" not found. Looked in: ${backendDir}/${functionName}{${extensions.join(',')}}`, + `Backend function "${functionName}" not found. Searched:\n - ${searchPaths.join('\n - ')}`, ); } /** - * Transform backend function code into Datadog App Builder script body format - * Wraps the function in a main() export that accepts the $ context + * Bundle backend function using esbuild directly + * This properly handles TypeScript, dependency resolution, and creates a single bundle + * without needing to resolve tsconfig.json files + */ +async function bundleBackendFunction( + functionName: string, + projectRoot: string, + viteServer: ViteDevServer | undefined, + log: Logger, +): Promise { + const filePath = await findBackendFunctionPath(functionName, projectRoot); + log.debug(`Found backend function at: ${filePath}`); + + // Create a temporary directory for the build output + const tempDir = path.join(tmpdir(), `dd-apps-bundle-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); + + log.debug(`Building bundle to: ${tempDir}`); + + const bundlePath = path.join(tempDir, 'bundle.js'); + + // Use a virtual entry that re-exports the backend function and also + // forces setExecuteActionImplementation into the bundle (esbuild would + // otherwise tree-shake it since no backend code calls it directly). + await esbuild.build({ + stdin: { + contents: [ + `export * from ${JSON.stringify(filePath)};`, + `export { setExecuteActionImplementation } from '@datadog/action-catalog/action-execution';`, + ].join('\n'), + resolveDir: projectRoot, + loader: 'ts', + }, + bundle: true, + format: 'esm', + platform: 'node', + target: 'esnext', + outfile: bundlePath, + absWorkingDir: projectRoot, // Set working directory for correct module resolution + conditions: ['node', 'import'], // Help resolve package.json exports for Node environment + mainFields: ['module', 'main'], // Fallback resolution for packages without exports + minify: false, + sourcemap: false, + // Mark Node.js built-ins as external + external: [ + 'fs', + 'path', + 'os', + 'http', + 'https', + 'crypto', + 'stream', + 'buffer', + 'util', + 'events', + 'url', + 'querystring', + ], + }); + + // Read the bundled output + const bundledCode = await readFile(bundlePath, 'utf-8'); + log.debug(`Bundled function (${bundledCode.length} bytes)`); + + // Clean up temp directory + await rm(tempDir, { recursive: true, force: true }); + + return bundledCode; +} + +/** + * Transform bundled backend function code into Datadog App Builder script body format + * The bundled code is already transformed JavaScript with dependencies resolved + * We need to wrap it in a main() export that accepts the $ context */ -function transformToScriptBody(functionCode: string, functionName: string, args: any[]): string { - // Remove any existing export keywords from the original function - const cleanedCode = functionCode.replace(/^export\s+(async\s+)?function/, 'async function'); +function transformToScriptBody(bundledCode: string, functionName: string, args: any[]): string { + // The bundled code from Vite contains the transformed function and its dependencies + // We need to clean up export statements and wrap it properly + let cleanedCode = bundledCode; + + // Remove export default statements and convert to regular function + cleanedCode = cleanedCode.replace(/export\s+default\s+/g, ''); + + // Convert named exports to regular declarations + cleanedCode = cleanedCode.replace(/export\s+(async\s+)?function\s+/g, '$1function '); + cleanedCode = cleanedCode.replace(/export\s+(const|let|var)\s+/g, '$1 '); - // Build the script body that wraps the original function + // Build the script body that includes the bundled code and wraps the function call const scriptBody = `import * as _ from 'lodash'; // Use \`_\` to access Lodash. See https://lodash.com/ for reference. @@ -80,6 +181,19 @@ ${cleanedCode} /** @param {import('./context.types').Context} $ */ export async function main($) { + // Register the $.Actions-based implementation for executeAction + setExecuteActionImplementation(async (actionId, request) => { + const actionPath = actionId.replace(/^com\\.datadoghq\\./, ''); + const pathParts = actionPath.split('.'); + let actionFn = $.Actions; + for (const part of pathParts) { + if (!actionFn) throw new Error('Action not found: ' + actionId); + actionFn = actionFn[part]; + } + if (typeof actionFn !== 'function') throw new Error('Action is not a function: ' + actionId); + return actionFn(request); + }); + // Execute the backend function with provided arguments const result = await ${functionName}(...${JSON.stringify(args)}); return result; @@ -97,13 +211,14 @@ async function pollActionExecution( apiKey: string, appKey: string, site: string, + log: Logger, ): Promise { const endpoint = `https://${site}/api/v2/workflows/${workflowId}/single_action_runs/${executionId}`; const maxAttempts = 30; // 30 attempts const pollInterval = 1000; // 1 second for (let attempt = 0; attempt < maxAttempts; attempt++) { - console.log(`Polling attempt ${attempt + 1}/${maxAttempts}...`); + log.debug(`Polling attempt ${attempt + 1}/${maxAttempts}...`); const response = await fetch(endpoint, { method: 'GET', @@ -119,15 +234,17 @@ async function pollActionExecution( throw new Error(`Datadog API error (${response.status}): ${errorText}`); } - const result = await response.json(); + const result = (await response.json()) as any; const state = result.data?.attributes?.state; - console.log(`Execution state: ${state}`); + log.debug(`Execution state: ${state}`); if (state === 'SUCCEEDED') { return result.data.attributes.outputs; - } else if (state === 'FAILED') { - throw new Error(`Action execution failed: ${JSON.stringify(result.data.attributes)}`); + } else if (state === 'FAILED' || state === 'EXECUTION_FAILED') { + const errorDetails = result.data.attributes.error || result.data.attributes; + log.debug(`Action execution failed: ${JSON.stringify(errorDetails)}`); + throw new Error(`Action execution failed: ${JSON.stringify(errorDetails)}`); } // Still pending, wait before next poll @@ -145,6 +262,7 @@ async function executeScriptViaDatadog( apiKey: string, appKey: string, site: string, + log: Logger, ): Promise { // Hardcoded workflow ID for development const workflowId = '380e7df1-729c-420c-b15e-a3b8e6347d49'; @@ -162,8 +280,9 @@ async function executeScriptViaDatadog( }, }, }; + log.debug(`Script body: ${JSON.stringify(requestBody.data.attributes.inputs.script)}`); - console.log(`Calling Datadog API: ${endpoint}`); + log.debug(`Calling Datadog API: ${endpoint}`); const response = await fetch(endpoint, { method: 'POST', @@ -180,17 +299,17 @@ async function executeScriptViaDatadog( throw new Error(`Datadog API error (${response.status}): ${errorText}`); } - const initialResult = await response.json(); + const initialResult = (await response.json()) as any; const executionId = initialResult.data?.id; if (!executionId) { throw new Error('No execution ID returned from Datadog API'); } - console.log(`Action started with ID: ${executionId}`); + log.debug(`Action started with ID: ${executionId}`); // Poll for result - const outputs = await pollActionExecution(workflowId, executionId, apiKey, appKey, site); + const outputs = await pollActionExecution(workflowId, executionId, apiKey, appKey, site, log); return outputs; } @@ -201,6 +320,43 @@ interface AuthConfig { site: string; } +/** + * Handle /__dd/debugBundle requests - returns the bundled code for inspection + */ +export async function handleDebugBundle( + req: IncomingMessage, + res: ServerResponse, + projectRoot: string, + log: Logger, + viteServer?: ViteDevServer, +): Promise { + try { + const { functionName } = await parseRequestBody(req); + + if (!functionName || typeof functionName !== 'string') { + res.statusCode = 400; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: 'Missing or invalid functionName' })); + return; + } + + const functionCode = await bundleBackendFunction( + functionName, + projectRoot, + viteServer, + log, + ); + + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end(functionCode); + } catch (error: any) { + res.statusCode = 500; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: error.message || 'Internal server error' })); + } +} + /** * Handle /__dd/executeAction requests */ @@ -209,6 +365,8 @@ export async function handleExecuteAction( res: ServerResponse, projectRoot: string, auth: AuthConfig, + log: Logger, + viteServer?: ViteDevServer, ): Promise { try { const { functionName, args = [] } = await parseRequestBody(req); @@ -225,15 +383,22 @@ export async function handleExecuteAction( return; } - console.log(`Executing action: ${functionName} with args:`, args); + log.debug(`Executing action: ${functionName} with args: ${JSON.stringify(args)}`); - // Load backend function file - const functionCode = await loadBackendFunction(functionName, projectRoot); - console.log(`Loaded function code (${functionCode.length} bytes)`); + // Bundle backend function file using Vite + const functionCode = await bundleBackendFunction( + functionName, + projectRoot, + viteServer, + log, + ); + log.debug(`Bundled function code (${functionCode.length} bytes)`); + log.debug(`Bundled code preview:\n${functionCode.substring(0, 500)}`); // Transform to script body const scriptBody = transformToScriptBody(functionCode, functionName, args); - console.log(`Transformed to script body (${scriptBody.length} bytes)`); + log.debug(`Transformed to script body (${scriptBody.length} bytes)`); + log.debug(`Script body preview:\n${scriptBody.substring(0, 500)}`); // Execute via Datadog API const apiResult = await executeScriptViaDatadog( @@ -241,8 +406,9 @@ export async function handleExecuteAction( auth.apiKey, auth.appKey, auth.site, + log, ); - console.log('Datadog API response:', apiResult); + log.debug('Datadog API response:', apiResult); // Return the result from Datadog res.statusCode = 200; @@ -254,7 +420,7 @@ export async function handleExecuteAction( } as ExecuteActionResponse), ); } catch (error: any) { - console.error('Error handling executeAction:', error); + log.debug(`Error handling executeAction: ${error}`); res.statusCode = 500; res.setHeader('Content-Type', 'application/json'); res.end( diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 06a0fcea..c02efdb5 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -9,8 +9,9 @@ import path from 'path'; import { createArchive } from './archive'; import { collectAssets } from './assets'; +import { publishBackendFunctions } from './backend-functions'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; -import { handleExecuteAction } from './dev-server'; +import { handleDebugBundle, handleExecuteAction } from './dev-server'; import { resolveIdentifier } from './identifier'; import type { AppsOptions } from './types'; import { uploadArchive } from './upload'; @@ -73,7 +74,11 @@ Either: archiveDir = path.dirname(archive.archivePath); const uploadTimer = log.time('upload assets'); - const { errors: uploadErrors, warnings: uploadWarnings } = await uploadArchive( + const { + errors: uploadErrors, + warnings: uploadWarnings, + uploadResponse, + } = await uploadArchive( archive, { apiKey: context.auth.apiKey, @@ -101,6 +106,37 @@ Either: .join('\n - '); throw new Error(` - ${listOfErrors}`); } + + // After successful upload, publish backend functions to the app definition. + if (uploadResponse && context.auth.apiKey && context.auth.appKey) { + const backendTimer = log.time('publish backend functions'); + const { errors: backendErrors, warnings: backendWarnings } = + await publishBackendFunctions( + context.buildRoot, + validatedOptions.backendDir, + uploadResponse.app_builder_id, + { + apiKey: context.auth.apiKey, + appKey: context.auth.appKey, + site: context.auth.site, + }, + log, + ); + backendTimer.end(); + + if (backendWarnings.length > 0) { + log.warn( + `${yellow('Warnings while publishing backend functions:')}\n - ${backendWarnings.join('\n - ')}`, + ); + } + + if (backendErrors.length > 0) { + const listOfErrors = backendErrors + .map((error) => error.cause || error.stack || error.message || error) + .join('\n - '); + throw new Error(` - ${listOfErrors}`); + } + } } catch (error: any) { toThrow = error; log.error(`${red('Failed to upload assets:')}\n${error?.message || error}`); @@ -128,13 +164,24 @@ Either: }, vite: { configureServer(server) { - server.middlewares.use((req, res, next) => { + server.middlewares.use(async (req, res, next) => { + if (req.url === '/__dd/debugBundle' && req.method === 'POST') { + await handleDebugBundle(req, res, context.buildRoot, log, server); + return; + } if (req.url === '/__dd/executeAction' && req.method === 'POST') { - handleExecuteAction(req, res, context.buildRoot, { - apiKey: context.auth.apiKey, - appKey: context.auth.appKey, - site: context.auth.site, - }); + await handleExecuteAction( + req, + res, + context.buildRoot, + { + apiKey: context.auth.apiKey || '', + appKey: context.auth.appKey || '', + site: context.auth.site, + }, + log, + server, + ); return; } next(); diff --git a/packages/plugins/apps/src/types.ts b/packages/plugins/apps/src/types.ts index f62ffd83..ef11e2d1 100644 --- a/packages/plugins/apps/src/types.ts +++ b/packages/plugins/apps/src/types.ts @@ -10,7 +10,11 @@ export type AppsOptions = { dryRun?: boolean; identifier?: string; name?: string; + backendDir?: string; }; // We don't enforce identifier, as it needs to be dynamically computed if absent. -export type AppsOptionsWithDefaults = WithRequired; +export type AppsOptionsWithDefaults = WithRequired< + AppsOptions, + 'enable' | 'include' | 'dryRun' | 'backendDir' +>; diff --git a/packages/plugins/apps/src/upload.ts b/packages/plugins/apps/src/upload.ts index f432a8d1..486beb76 100644 --- a/packages/plugins/apps/src/upload.ts +++ b/packages/plugins/apps/src/upload.ts @@ -70,9 +70,16 @@ export const getData = }); }; +export type UploadResponse = { + app_builder_id: string; + application_id: string; + version_id: string; +}; + export const uploadArchive = async (archive: Archive, context: UploadContext, log: Logger) => { const errors: Error[] = []; const warnings: string[] = []; + let uploadResponse: UploadResponse | undefined; if (!context.apiKey || !context.appKey) { errors.push(new Error('Missing authentication token, need both app and api keys.')); @@ -139,6 +146,8 @@ Would have uploaded ${summary}`, log.info( `Your application is available at:\n${bold('Standalone :')}\n ${cyan(appUrl)}\n\n${bold('AppBuilder :')}\n ${cyan(appBuilderUrl)}`, ); + + uploadResponse = { app_builder_id, application_id, version_id }; } const versionName = getDDEnvValue('APPS_VERSION_NAME')?.trim(); @@ -171,5 +180,5 @@ Would have uploaded ${summary}`, errors.push(err); } - return { errors, warnings }; + return { errors, warnings, uploadResponse }; }; diff --git a/packages/plugins/apps/src/validate.ts b/packages/plugins/apps/src/validate.ts index bd150906..39245080 100644 --- a/packages/plugins/apps/src/validate.ts +++ b/packages/plugins/apps/src/validate.ts @@ -18,6 +18,7 @@ export const validateOptions = (options: Options): AppsOptionsWithDefaults => { dryRun: resolvedOptions.dryRun ?? !getDDEnvValue('APPS_UPLOAD_ASSETS'), identifier: resolvedOptions.identifier?.trim(), name: resolvedOptions.name?.trim() || options.metadata?.name?.trim(), + backendDir: resolvedOptions.backendDir?.trim() || 'backend', }; return validatedOptions; diff --git a/packages/published/esbuild-plugin/package.json b/packages/published/esbuild-plugin/package.json index 4195a96a..c71ab03f 100644 --- a/packages/published/esbuild-plugin/package.json +++ b/packages/published/esbuild-plugin/package.json @@ -89,8 +89,11 @@ "esbuild": ">=0.x" }, "previousExports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } } } diff --git a/packages/published/rollup-plugin/package.json b/packages/published/rollup-plugin/package.json index 479f5aef..d5e78186 100644 --- a/packages/published/rollup-plugin/package.json +++ b/packages/published/rollup-plugin/package.json @@ -90,10 +90,11 @@ "rollup": ">= 3.x < 5.x" }, "previousExports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - "./dist-basic/src": "./dist-basic/src/index.js", - "./dist-basic/src/*": "./dist-basic/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } } } diff --git a/packages/published/rspack-plugin/package.json b/packages/published/rspack-plugin/package.json index b558a99d..9491863e 100644 --- a/packages/published/rspack-plugin/package.json +++ b/packages/published/rspack-plugin/package.json @@ -89,8 +89,11 @@ "@rspack/core": "1.x" }, "previousExports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } } } diff --git a/packages/published/vite-plugin/package.json b/packages/published/vite-plugin/package.json index b35131ca..62728893 100644 --- a/packages/published/vite-plugin/package.json +++ b/packages/published/vite-plugin/package.json @@ -56,6 +56,7 @@ "@datadog/js-instrumentation-wasm": "1.0.8", "async-retry": "1.3.3", "chalk": "2.3.1", + "esbuild": "0.25.8", "glob": "11.1.0", "json-stream-stringify": "3.1.6", "jszip": "3.10.1", @@ -79,7 +80,6 @@ "@rollup/plugin-terser": "0.4.4", "@types/babel__core": "^7", "@types/babel__preset-env": "^7", - "esbuild": "0.25.8", "rollup": "4.45.1", "rollup-plugin-dts": "6.1.1", "rollup-plugin-esbuild": "6.1.1", @@ -89,8 +89,11 @@ "vite": ">= 5.x <= 7.x" }, "previousExports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } } } diff --git a/packages/published/webpack-plugin/package.json b/packages/published/webpack-plugin/package.json index 5688c2d6..943ec386 100644 --- a/packages/published/webpack-plugin/package.json +++ b/packages/published/webpack-plugin/package.json @@ -89,8 +89,11 @@ "webpack": ">= 5.x < 6.x" }, "previousExports": { - "./dist/src": "./dist/src/index.js", - "./dist/src/*": "./dist/src/*", - ".": "./src/index.ts" + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } } }