From 55a3e9eb96be8dc42c240a36b61c8ec099c6a29f Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Tue, 10 Mar 2026 13:32:25 -0400 Subject: [PATCH 1/2] [APPS] Support uploading backend functions to app definitions --- packages/plugins/apps/package.json | 3 +- .../plugins/apps/src/backend-functions.ts | 297 ++++++++++++++++++ packages/plugins/apps/src/backend-shared.ts | 55 ++++ packages/plugins/apps/src/index.ts | 38 ++- 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 | 17 +- packages/published/rollup-plugin/package.json | 19 +- packages/published/rspack-plugin/package.json | 17 +- packages/published/vite-plugin/package.json | 19 +- .../published/webpack-plugin/package.json | 17 +- 12 files changed, 478 insertions(+), 22 deletions(-) create mode 100644 packages/plugins/apps/src/backend-functions.ts create mode 100644 packages/plugins/apps/src/backend-shared.ts diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 46ac7889..c6f3eb72 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", + "esbuild": "0.25.8" }, "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..c02d5d82 --- /dev/null +++ b/packages/plugins/apps/src/backend-functions.ts @@ -0,0 +1,297 @@ +// 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'; + +import { + ACTION_CATALOG_EXPORT_LINE, + NODE_EXTERNALS, + SET_EXECUTE_ACTION_SNIPPET, + isActionCatalogInstalled, +} from './backend-shared'; + +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'; + +/** + * 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; +} + +/** + * Build the stdin contents for esbuild bundling. + * Only forces action-catalog into the bundle if it is installed. + */ +function buildStdinContents(filePath: string): string { + const lines = [`export * from ${JSON.stringify(filePath)};`]; + + if (isActionCatalogInstalled()) { + lines.push(ACTION_CATALOG_EXPORT_LINE); + } + + return lines.join('\n'); +} + +/** + * 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: buildStdinContents(func.entryPath), + 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 +${SET_EXECUTE_ACTION_SNIPPET} + + const args = JSON.parse('\${backendFunctionArgs}' || '[]'); + const result = await ${functionName}(...args); + 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/backend-shared.ts b/packages/plugins/apps/src/backend-shared.ts new file mode 100644 index 00000000..b7746e4e --- /dev/null +++ b/packages/plugins/apps/src/backend-shared.ts @@ -0,0 +1,55 @@ +// 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 { createRequire } from 'node:module'; + +/** Node built-in modules to mark as external during esbuild bundling. */ +export const NODE_EXTERNALS = [ + 'fs', + 'path', + 'os', + 'http', + 'https', + 'crypto', + 'stream', + 'buffer', + 'util', + 'events', + 'url', + 'querystring', +]; + +/** + * Check if @datadog/action-catalog is installed using Node's module resolution. + * Works across all package managers (npm, yarn, yarn PnP, pnpm). + */ +export function isActionCatalogInstalled(): boolean { + const req = createRequire(import.meta.url); + try { + req.resolve('@datadog/action-catalog/action-execution'); + return true; + } catch { + return false; + } +} + +/** The export line to force action-catalog's setExecuteActionImplementation into esbuild bundles. */ +export const ACTION_CATALOG_EXPORT_LINE = + "export { setExecuteActionImplementation } from '@datadog/action-catalog/action-execution';"; + +/** Script snippet that registers the $.Actions-based executeAction implementation at runtime. */ +export const SET_EXECUTE_ACTION_SNIPPET = `\ + if (typeof setExecuteActionImplementation === 'function') { + 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); + }); + }`; diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 3fc9807e..4ebdede9 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -9,6 +9,7 @@ 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 { resolveIdentifier } from './identifier'; import type { AppsOptions } from './types'; @@ -72,7 +73,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, @@ -100,6 +105,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}`); 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 bc601479..c71ab03f 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,13 @@ }, "peerDependencies": { "esbuild": ">=0.x" + }, + "previousExports": { + "./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 561ba2dd..d5e78186 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,13 @@ }, "peerDependencies": { "rollup": ">= 3.x < 5.x" + }, + "previousExports": { + "./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 82f567a3..9491863e 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,13 @@ }, "peerDependencies": { "@rspack/core": "1.x" + }, + "previousExports": { + "./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 bc5eb7ad..62728893 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", @@ -53,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", @@ -76,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", @@ -84,5 +87,13 @@ }, "peerDependencies": { "vite": ">= 5.x <= 7.x" + }, + "previousExports": { + "./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 af8aa936..943ec386 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,13 @@ }, "peerDependencies": { "webpack": ">= 5.x < 6.x" + }, + "previousExports": { + "./package.json": "./package.json", + ".": { + "import": "./dist/src/index.mjs", + "require": "./dist/src/index.js", + "types": "./dist/src/index.d.ts" + } } } From c785dd3d8d0203c77096dc01b349287ccb0956d3 Mon Sep 17 00:00:00 2001 From: Scott Kennedy Date: Tue, 10 Mar 2026 15:20:34 -0400 Subject: [PATCH 2/2] Refactor backend functions to bundle into upload archive instead of separate API call --- .../plugins/apps/src/backend-functions.ts | 145 ++++-------------- packages/plugins/apps/src/index.test.ts | 8 +- packages/plugins/apps/src/index.ts | 61 +++----- packages/plugins/apps/src/upload.test.ts | 2 +- packages/plugins/apps/src/validate.test.ts | 4 + 5 files changed, 60 insertions(+), 160 deletions(-) diff --git a/packages/plugins/apps/src/backend-functions.ts b/packages/plugins/apps/src/backend-functions.ts index c02d5d82..dabcf901 100644 --- a/packages/plugins/apps/src/backend-functions.ts +++ b/packages/plugins/apps/src/backend-functions.ts @@ -3,12 +3,12 @@ // 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 { mkdir, readdir, readFile, rm, stat, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; +import type { Asset } from './assets'; import { ACTION_CATALOG_EXPORT_LINE, NODE_EXTERNALS, @@ -21,28 +21,7 @@ export interface BackendFunction { 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'; /** * Discover backend functions in the backend directory. @@ -189,109 +168,37 @@ ${SET_EXECUTE_ACTION_SNIPPET} } /** - * 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} + * Discover, bundle, and transform backend functions for inclusion in the upload archive. + * Writes transformed scripts to temp files and returns file references for archiving. */ -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( +export async function bundleBackendFunctions( 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); +): Promise<{ files: Asset[]; tempDir: string }> { + 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 }); - } + if (functions.length === 0) { + log.debug('No backend functions found.'); + return { files: [], tempDir: '' }; + } - // Build queries and update the app - const queries = buildQueries(transformedFunctions); - await updateApp(appBuilderId, queries, auth, log); + const tempDir = path.join(tmpdir(), `dd-apps-backend-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); - 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); + const files: Asset[] = []; + for (const func of functions) { + const bundledCode = await bundleFunction(func, projectRoot, log); + const script = transformToProductionScript(bundledCode, func.name); + const absolutePath = path.join(tempDir, `${func.name}.js`); + await writeFile(absolutePath, script, 'utf-8'); + files.push({ absolutePath, relativePath: `backend/${func.name}.js` }); } - return { errors, warnings }; + log.info( + `Bundled ${files.length} backend function(s): ${functions.map((f) => f.name).join(', ')}`, + ); + + return { files, tempDir }; } diff --git a/packages/plugins/apps/src/index.test.ts b/packages/plugins/apps/src/index.test.ts index bc3bae60..c22cfcee 100644 --- a/packages/plugins/apps/src/index.test.ts +++ b/packages/plugins/apps/src/index.test.ts @@ -20,6 +20,10 @@ import path from 'path'; import { APPS_API_PATH } from './constants'; +jest.mock('@dd/apps-plugin/backend-functions', () => ({ + bundleBackendFunctions: jest.fn().mockResolvedValue({ files: [], tempDir: '' }), +})); + describe('Apps Plugin - getPlugins', () => { const buildRoot = '/project'; const outDir = '/project/dist'; @@ -129,7 +133,9 @@ describe('Apps Plugin - getPlugins', () => { await plugin.asyncTrueEnd?.(); expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], buildRoot); - expect(archive.createArchive).toHaveBeenCalledWith(mockedAssets); + expect(archive.createArchive).toHaveBeenCalledWith( + mockedAssets.map((a) => ({ ...a, relativePath: `frontend/${a.relativePath}` })), + ); expect(uploader.uploadArchive).toHaveBeenCalledWith( expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }), { diff --git a/packages/plugins/apps/src/index.ts b/packages/plugins/apps/src/index.ts index 4ebdede9..4d1fcdb5 100644 --- a/packages/plugins/apps/src/index.ts +++ b/packages/plugins/apps/src/index.ts @@ -9,7 +9,7 @@ import path from 'path'; import { createArchive } from './archive'; import { collectAssets } from './assets'; -import { publishBackendFunctions } from './backend-functions'; +import { bundleBackendFunctions } from './backend-functions'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import { resolveIdentifier } from './identifier'; import type { AppsOptions } from './types'; @@ -37,6 +37,7 @@ export const getPlugins: GetPlugins = ({ options, context }) => { const handleUpload = async () => { const handleTimer = log.time('handle assets'); let archiveDir: string | undefined; + let backendTempDir: string | undefined; try { const identifierTimer = log.time('resolve identifier'); @@ -66,18 +67,28 @@ Either: return; } + const bundleTimer = log.time('bundle backend functions'); + const { files: backendAssets, tempDir } = await bundleBackendFunctions( + context.buildRoot, + validatedOptions.backendDir, + log, + ); + backendTempDir = tempDir || undefined; + bundleTimer.end(); + + const frontendAssets = assets.map((asset) => ({ + ...asset, + relativePath: `frontend/${asset.relativePath}`, + })); + const archiveTimer = log.time('archive assets'); - const archive = await createArchive(assets); + const archive = await createArchive([...frontendAssets, ...backendAssets]); archiveTimer.end(); // Store variable for later disposal of directory. archiveDir = path.dirname(archive.archivePath); const uploadTimer = log.time('upload assets'); - const { - errors: uploadErrors, - warnings: uploadWarnings, - uploadResponse, - } = await uploadArchive( + const { errors: uploadErrors, warnings: uploadWarnings } = await uploadArchive( archive, { apiKey: context.auth.apiKey, @@ -105,46 +116,18 @@ 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}`); } - // Clean temporary directory + // Clean temporary directories if (archiveDir) { await rm(archiveDir); } + if (backendTempDir) { + await rm(backendTempDir); + } handleTimer.end(); if (toThrow) { diff --git a/packages/plugins/apps/src/upload.test.ts b/packages/plugins/apps/src/upload.test.ts index 0a4756e2..20c91795 100644 --- a/packages/plugins/apps/src/upload.test.ts +++ b/packages/plugins/apps/src/upload.test.ts @@ -45,7 +45,7 @@ const getOriginHeadersMock = jest.mocked(getOriginHeaders); describe('Apps Plugin - upload', () => { const archive = { archivePath: '/tmp/datadog-apps-assets.zip', - assets: [{ absolutePath: '/tmp/a.js', relativePath: 'a.js' }], + assets: [{ absolutePath: '/tmp/a.js', relativePath: 'frontend/a.js' }], size: 1234, }; const context = { diff --git a/packages/plugins/apps/src/validate.test.ts b/packages/plugins/apps/src/validate.test.ts index ec07c894..3417c0f0 100644 --- a/packages/plugins/apps/src/validate.test.ts +++ b/packages/plugins/apps/src/validate.test.ts @@ -39,10 +39,12 @@ describe('Apps Plugin - validateOptions', () => { test('Should set defaults when nothing is provided', () => { const result = validateOptions({}); expect(result).toEqual({ + backendDir: 'backend', dryRun: true, enable: false, include: [], identifier: undefined, + name: undefined, }); }); @@ -89,10 +91,12 @@ describe('Apps Plugin - validateOptions', () => { }); expect(result).toEqual({ + backendDir: 'backend', dryRun: true, enable: true, include: ['public/**/*', 'dist/**/*'], identifier: 'my-app', + name: undefined, }); }); });