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..dabcf901 --- /dev/null +++ b/packages/plugins/apps/src/backend-functions.ts @@ -0,0 +1,204 @@ +// 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 * as esbuild from 'esbuild'; +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, + SET_EXECUTE_ACTION_SNIPPET, + isActionCatalogInstalled, +} from './backend-shared'; + +export interface BackendFunction { + name: string; + entryPath: string; +} + +const EXTENSIONS = ['.ts', '.js', '.tsx', '.jsx']; + +/** + * 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; +} + +/** + * Discover, bundle, and transform backend functions for inclusion in the upload archive. + * Writes transformed scripts to temp files and returns file references for archiving. + */ +export async function bundleBackendFunctions( + projectRoot: string, + backendDir: string, + log: Logger, +): 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.'); + return { files: [], tempDir: '' }; + } + + const tempDir = path.join(tmpdir(), `dd-apps-backend-${Date.now()}`); + await mkdir(tempDir, { recursive: true }); + + 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` }); + } + + log.info( + `Bundled ${files.length} backend function(s): ${functions.map((f) => f.name).join(', ')}`, + ); + + return { files, tempDir }; +} 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.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 3fc9807e..4d1fcdb5 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 { bundleBackendFunctions } from './backend-functions'; import { CONFIG_KEY, PLUGIN_NAME } from './constants'; import { resolveIdentifier } from './identifier'; import type { AppsOptions } from './types'; @@ -36,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'); @@ -65,8 +67,22 @@ 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); @@ -105,10 +121,13 @@ Either: 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/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.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/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.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, }); }); }); 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" + } } }