Skip to content

Commit 55a3e9e

Browse files
committed
[APPS] Support uploading backend functions to app definitions
1 parent 785fd9e commit 55a3e9e

File tree

12 files changed

+478
-22
lines changed

12 files changed

+478
-22
lines changed

packages/plugins/apps/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,8 @@
2626
"chalk": "2.3.1",
2727
"glob": "11.1.0",
2828
"jszip": "3.10.1",
29-
"pretty-bytes": "5.6.0"
29+
"pretty-bytes": "5.6.0",
30+
"esbuild": "0.25.8"
3031
},
3132
"devDependencies": {
3233
"typescript": "5.4.3"
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import type { Logger } from '@dd/core/types';
6+
import { randomUUID } from 'crypto';
7+
import * as esbuild from 'esbuild';
8+
import { mkdir, readdir, readFile, rm, stat } from 'fs/promises';
9+
import { tmpdir } from 'os';
10+
import path from 'path';
11+
12+
import {
13+
ACTION_CATALOG_EXPORT_LINE,
14+
NODE_EXTERNALS,
15+
SET_EXECUTE_ACTION_SNIPPET,
16+
isActionCatalogInstalled,
17+
} from './backend-shared';
18+
19+
export interface BackendFunction {
20+
name: string;
21+
entryPath: string;
22+
}
23+
24+
interface BackendFunctionQuery {
25+
id: string;
26+
type: string;
27+
name: string;
28+
properties: {
29+
spec: {
30+
fqn: string;
31+
inputs: {
32+
script: string;
33+
};
34+
};
35+
};
36+
}
37+
38+
interface AuthConfig {
39+
apiKey: string;
40+
appKey: string;
41+
site: string;
42+
}
43+
44+
const EXTENSIONS = ['.ts', '.js', '.tsx', '.jsx'];
45+
const JS_FUNCTION_WITH_ACTIONS_FQN = 'com.datadoghq.datatransformation.jsFunctionWithActions';
46+
47+
/**
48+
* Discover backend functions in the backend directory.
49+
* Supports two patterns:
50+
* - Single file module: backend/functionName.{ts,js,tsx,jsx}
51+
* - Directory module: backend/functionName/index.{ts,js,tsx,jsx}
52+
*/
53+
export async function discoverBackendFunctions(
54+
backendDir: string,
55+
log: Logger,
56+
): Promise<BackendFunction[]> {
57+
let entries: string[];
58+
try {
59+
entries = await readdir(backendDir);
60+
} catch (error: any) {
61+
if (error.code === 'ENOENT') {
62+
log.debug(`No backend directory found at ${backendDir}`);
63+
return [];
64+
}
65+
throw error;
66+
}
67+
68+
const functions: BackendFunction[] = [];
69+
70+
for (const entry of entries) {
71+
const entryPath = path.join(backendDir, entry);
72+
const entryStat = await stat(entryPath);
73+
74+
if (entryStat.isDirectory()) {
75+
// Directory module: backend/functionName/index.{ext}
76+
for (const ext of EXTENSIONS) {
77+
const indexPath = path.join(entryPath, `index${ext}`);
78+
try {
79+
await stat(indexPath);
80+
functions.push({ name: entry, entryPath: indexPath });
81+
break;
82+
} catch {
83+
// Try next extension
84+
}
85+
}
86+
} else if (entryStat.isFile()) {
87+
// Single file module: backend/functionName.{ext}
88+
const ext = path.extname(entry);
89+
if (EXTENSIONS.includes(ext)) {
90+
const name = path.basename(entry, ext);
91+
functions.push({ name, entryPath });
92+
}
93+
}
94+
}
95+
96+
log.debug(
97+
`Discovered ${functions.length} backend function(s): ${functions.map((f) => f.name).join(', ')}`,
98+
);
99+
return functions;
100+
}
101+
102+
/**
103+
* Build the stdin contents for esbuild bundling.
104+
* Only forces action-catalog into the bundle if it is installed.
105+
*/
106+
function buildStdinContents(filePath: string): string {
107+
const lines = [`export * from ${JSON.stringify(filePath)};`];
108+
109+
if (isActionCatalogInstalled()) {
110+
lines.push(ACTION_CATALOG_EXPORT_LINE);
111+
}
112+
113+
return lines.join('\n');
114+
}
115+
116+
/**
117+
* Bundle a backend function using esbuild.
118+
* Same approach as dev-server.ts bundleBackendFunction but without vite server dependency.
119+
*/
120+
async function bundleFunction(
121+
func: BackendFunction,
122+
projectRoot: string,
123+
log: Logger,
124+
): Promise<string> {
125+
const tempDir = path.join(tmpdir(), `dd-apps-backend-bundle-${Date.now()}`);
126+
await mkdir(tempDir, { recursive: true });
127+
128+
const bundlePath = path.join(tempDir, 'bundle.js');
129+
130+
try {
131+
await esbuild.build({
132+
stdin: {
133+
contents: buildStdinContents(func.entryPath),
134+
resolveDir: projectRoot,
135+
loader: 'ts',
136+
},
137+
bundle: true,
138+
format: 'esm',
139+
platform: 'node',
140+
target: 'esnext',
141+
outfile: bundlePath,
142+
absWorkingDir: projectRoot,
143+
conditions: ['node', 'import'],
144+
mainFields: ['module', 'main'],
145+
minify: false,
146+
sourcemap: false,
147+
external: NODE_EXTERNALS,
148+
});
149+
150+
const bundledCode = await readFile(bundlePath, 'utf-8');
151+
log.debug(`Bundled backend function "${func.name}" (${bundledCode.length} bytes)`);
152+
return bundledCode;
153+
} finally {
154+
await rm(tempDir, { recursive: true, force: true });
155+
}
156+
}
157+
158+
/**
159+
* Transform bundled code into the Action Platform script format.
160+
* Per the RFC, the script is wrapped in a main($) entry point with globalThis.$ = $.
161+
* Args are passed via App Builder's template expression system (backendFunctionRequest).
162+
*/
163+
function transformToProductionScript(bundledCode: string, functionName: string): string {
164+
let cleanedCode = bundledCode;
165+
166+
// Remove export default statements
167+
cleanedCode = cleanedCode.replace(/export\s+default\s+/g, '');
168+
// Convert named exports to regular declarations
169+
cleanedCode = cleanedCode.replace(/export\s+(async\s+)?function\s+/g, '$1function ');
170+
cleanedCode = cleanedCode.replace(/export\s+(const|let|var)\s+/g, '$1 ');
171+
172+
// The backendFunctionRequest template param is resolved at query execution time
173+
// by the executeBackendFunction client via the template_params mechanism.
174+
const scriptBody = `${cleanedCode}
175+
176+
/** @param {import('./context.types').Context} $ */
177+
export async function main($) {
178+
globalThis.$ = $;
179+
180+
// Register the $.Actions-based implementation for executeAction
181+
${SET_EXECUTE_ACTION_SNIPPET}
182+
183+
const args = JSON.parse('\${backendFunctionArgs}' || '[]');
184+
const result = await ${functionName}(...args);
185+
return result;
186+
}`;
187+
188+
return scriptBody;
189+
}
190+
191+
/**
192+
* Build the ActionQuery objects for each backend function.
193+
*/
194+
function buildQueries(functions: { name: string; script: string }[]): BackendFunctionQuery[] {
195+
return functions.map((func) => ({
196+
id: randomUUID(),
197+
type: 'action',
198+
name: func.name,
199+
properties: {
200+
spec: {
201+
fqn: JS_FUNCTION_WITH_ACTIONS_FQN,
202+
inputs: {
203+
script: func.script,
204+
},
205+
},
206+
},
207+
}));
208+
}
209+
210+
/**
211+
* Call the Update App endpoint to set backend function queries on the app definition.
212+
* PATCH /api/v2/app-builder/apps/{app_builder_id}
213+
*/
214+
async function updateApp(
215+
appBuilderId: string,
216+
queries: BackendFunctionQuery[],
217+
auth: AuthConfig,
218+
log: Logger,
219+
): Promise<void> {
220+
const endpoint = `https://api.${auth.site}/api/v2/app-builder/apps/${appBuilderId}`;
221+
222+
const body = {
223+
data: {
224+
type: 'appDefinitions',
225+
attributes: {
226+
queries,
227+
},
228+
},
229+
};
230+
231+
log.debug(`Updating app ${appBuilderId} with ${queries.length} backend function query(ies)`);
232+
233+
const response = await fetch(endpoint, {
234+
method: 'PATCH',
235+
headers: {
236+
'Content-Type': 'application/json',
237+
'DD-API-KEY': auth.apiKey,
238+
'DD-APPLICATION-KEY': auth.appKey,
239+
},
240+
body: JSON.stringify(body),
241+
});
242+
243+
if (!response.ok) {
244+
const errorText = await response.text();
245+
throw new Error(
246+
`Failed to update app with backend functions (${response.status}): ${errorText}`,
247+
);
248+
}
249+
250+
log.debug(`Successfully updated app ${appBuilderId} with backend function queries`);
251+
}
252+
253+
/**
254+
* Discover, bundle, transform, and publish backend functions to the app definition.
255+
* Called after a successful app upload to emulate backend function support.
256+
*/
257+
export async function publishBackendFunctions(
258+
projectRoot: string,
259+
backendDir: string,
260+
appBuilderId: string,
261+
auth: AuthConfig,
262+
log: Logger,
263+
): Promise<{ errors: Error[]; warnings: string[] }> {
264+
const errors: Error[] = [];
265+
const warnings: string[] = [];
266+
267+
try {
268+
const absoluteBackendDir = path.resolve(projectRoot, backendDir);
269+
const functions = await discoverBackendFunctions(absoluteBackendDir, log);
270+
271+
if (functions.length === 0) {
272+
log.debug('No backend functions found, skipping update.');
273+
return { errors, warnings };
274+
}
275+
276+
// Bundle and transform each function
277+
const transformedFunctions: { name: string; script: string }[] = [];
278+
for (const func of functions) {
279+
const bundledCode = await bundleFunction(func, projectRoot, log);
280+
const script = transformToProductionScript(bundledCode, func.name);
281+
transformedFunctions.push({ name: func.name, script });
282+
}
283+
284+
// Build queries and update the app
285+
const queries = buildQueries(transformedFunctions);
286+
await updateApp(appBuilderId, queries, auth, log);
287+
288+
log.info(
289+
`Published ${transformedFunctions.length} backend function(s): ${transformedFunctions.map((f) => f.name).join(', ')}`,
290+
);
291+
} catch (error: unknown) {
292+
const err = error instanceof Error ? error : new Error(String(error));
293+
errors.push(err);
294+
}
295+
296+
return { errors, warnings };
297+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License.
2+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
3+
// Copyright 2019-Present Datadog, Inc.
4+
5+
import { createRequire } from 'node:module';
6+
7+
/** Node built-in modules to mark as external during esbuild bundling. */
8+
export const NODE_EXTERNALS = [
9+
'fs',
10+
'path',
11+
'os',
12+
'http',
13+
'https',
14+
'crypto',
15+
'stream',
16+
'buffer',
17+
'util',
18+
'events',
19+
'url',
20+
'querystring',
21+
];
22+
23+
/**
24+
* Check if @datadog/action-catalog is installed using Node's module resolution.
25+
* Works across all package managers (npm, yarn, yarn PnP, pnpm).
26+
*/
27+
export function isActionCatalogInstalled(): boolean {
28+
const req = createRequire(import.meta.url);
29+
try {
30+
req.resolve('@datadog/action-catalog/action-execution');
31+
return true;
32+
} catch {
33+
return false;
34+
}
35+
}
36+
37+
/** The export line to force action-catalog's setExecuteActionImplementation into esbuild bundles. */
38+
export const ACTION_CATALOG_EXPORT_LINE =
39+
"export { setExecuteActionImplementation } from '@datadog/action-catalog/action-execution';";
40+
41+
/** Script snippet that registers the $.Actions-based executeAction implementation at runtime. */
42+
export const SET_EXECUTE_ACTION_SNIPPET = `\
43+
if (typeof setExecuteActionImplementation === 'function') {
44+
setExecuteActionImplementation(async (actionId, request) => {
45+
const actionPath = actionId.replace(/^com\\.datadoghq\\./, '');
46+
const pathParts = actionPath.split('.');
47+
let actionFn = $.Actions;
48+
for (const part of pathParts) {
49+
if (!actionFn) throw new Error('Action not found: ' + actionId);
50+
actionFn = actionFn[part];
51+
}
52+
if (typeof actionFn !== 'function') throw new Error('Action is not a function: ' + actionId);
53+
return actionFn(request);
54+
});
55+
}`;

0 commit comments

Comments
 (0)