Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/plugins/apps/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
204 changes: 204 additions & 0 deletions packages/plugins/apps/src/backend-functions.ts
Original file line number Diff line number Diff line change
@@ -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<BackendFunction[]> {
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<string> {
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 };
}
55 changes: 55 additions & 0 deletions packages/plugins/apps/src/backend-shared.ts
Original file line number Diff line number Diff line change
@@ -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);
});
}`;
8 changes: 7 additions & 1 deletion packages/plugins/apps/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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' }),
{
Expand Down
23 changes: 21 additions & 2 deletions packages/plugins/apps/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
6 changes: 5 additions & 1 deletion packages/plugins/apps/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppsOptions, 'enable' | 'include' | 'dryRun'>;
export type AppsOptionsWithDefaults = WithRequired<
AppsOptions,
'enable' | 'include' | 'dryRun' | 'backendDir'
>;
2 changes: 1 addition & 1 deletion packages/plugins/apps/src/upload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading