Skip to content

Commit c785dd3

Browse files
committed
Refactor backend functions to bundle into upload archive instead of separate API call
1 parent 55a3e9e commit c785dd3

File tree

5 files changed

+60
-160
lines changed

5 files changed

+60
-160
lines changed

packages/plugins/apps/src/backend-functions.ts

Lines changed: 26 additions & 119 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@
33
// Copyright 2019-Present Datadog, Inc.
44

55
import type { Logger } from '@dd/core/types';
6-
import { randomUUID } from 'crypto';
76
import * as esbuild from 'esbuild';
8-
import { mkdir, readdir, readFile, rm, stat } from 'fs/promises';
7+
import { mkdir, readdir, readFile, rm, stat, writeFile } from 'fs/promises';
98
import { tmpdir } from 'os';
109
import path from 'path';
1110

11+
import type { Asset } from './assets';
1212
import {
1313
ACTION_CATALOG_EXPORT_LINE,
1414
NODE_EXTERNALS,
@@ -21,28 +21,7 @@ export interface BackendFunction {
2121
entryPath: string;
2222
}
2323

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-
4424
const EXTENSIONS = ['.ts', '.js', '.tsx', '.jsx'];
45-
const JS_FUNCTION_WITH_ACTIONS_FQN = 'com.datadoghq.datatransformation.jsFunctionWithActions';
4625

4726
/**
4827
* Discover backend functions in the backend directory.
@@ -189,109 +168,37 @@ ${SET_EXECUTE_ACTION_SNIPPET}
189168
}
190169

191170
/**
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}
171+
* Discover, bundle, and transform backend functions for inclusion in the upload archive.
172+
* Writes transformed scripts to temp files and returns file references for archiving.
213173
*/
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(
174+
export async function bundleBackendFunctions(
258175
projectRoot: string,
259176
backendDir: string,
260-
appBuilderId: string,
261-
auth: AuthConfig,
262177
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);
178+
): Promise<{ files: Asset[]; tempDir: string }> {
179+
const absoluteBackendDir = path.resolve(projectRoot, backendDir);
180+
const functions = await discoverBackendFunctions(absoluteBackendDir, log);
270181

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-
}
182+
if (functions.length === 0) {
183+
log.debug('No backend functions found.');
184+
return { files: [], tempDir: '' };
185+
}
283186

284-
// Build queries and update the app
285-
const queries = buildQueries(transformedFunctions);
286-
await updateApp(appBuilderId, queries, auth, log);
187+
const tempDir = path.join(tmpdir(), `dd-apps-backend-${Date.now()}`);
188+
await mkdir(tempDir, { recursive: true });
287189

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);
190+
const files: Asset[] = [];
191+
for (const func of functions) {
192+
const bundledCode = await bundleFunction(func, projectRoot, log);
193+
const script = transformToProductionScript(bundledCode, func.name);
194+
const absolutePath = path.join(tempDir, `${func.name}.js`);
195+
await writeFile(absolutePath, script, 'utf-8');
196+
files.push({ absolutePath, relativePath: `backend/${func.name}.js` });
294197
}
295198

296-
return { errors, warnings };
199+
log.info(
200+
`Bundled ${files.length} backend function(s): ${functions.map((f) => f.name).join(', ')}`,
201+
);
202+
203+
return { files, tempDir };
297204
}

packages/plugins/apps/src/index.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import path from 'path';
2020

2121
import { APPS_API_PATH } from './constants';
2222

23+
jest.mock('@dd/apps-plugin/backend-functions', () => ({
24+
bundleBackendFunctions: jest.fn().mockResolvedValue({ files: [], tempDir: '' }),
25+
}));
26+
2327
describe('Apps Plugin - getPlugins', () => {
2428
const buildRoot = '/project';
2529
const outDir = '/project/dist';
@@ -129,7 +133,9 @@ describe('Apps Plugin - getPlugins', () => {
129133
await plugin.asyncTrueEnd?.();
130134

131135
expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], buildRoot);
132-
expect(archive.createArchive).toHaveBeenCalledWith(mockedAssets);
136+
expect(archive.createArchive).toHaveBeenCalledWith(
137+
mockedAssets.map((a) => ({ ...a, relativePath: `frontend/${a.relativePath}` })),
138+
);
133139
expect(uploader.uploadArchive).toHaveBeenCalledWith(
134140
expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }),
135141
{

packages/plugins/apps/src/index.ts

Lines changed: 22 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import path from 'path';
99

1010
import { createArchive } from './archive';
1111
import { collectAssets } from './assets';
12-
import { publishBackendFunctions } from './backend-functions';
12+
import { bundleBackendFunctions } from './backend-functions';
1313
import { CONFIG_KEY, PLUGIN_NAME } from './constants';
1414
import { resolveIdentifier } from './identifier';
1515
import type { AppsOptions } from './types';
@@ -37,6 +37,7 @@ export const getPlugins: GetPlugins = ({ options, context }) => {
3737
const handleUpload = async () => {
3838
const handleTimer = log.time('handle assets');
3939
let archiveDir: string | undefined;
40+
let backendTempDir: string | undefined;
4041
try {
4142
const identifierTimer = log.time('resolve identifier');
4243

@@ -66,18 +67,28 @@ Either:
6667
return;
6768
}
6869

70+
const bundleTimer = log.time('bundle backend functions');
71+
const { files: backendAssets, tempDir } = await bundleBackendFunctions(
72+
context.buildRoot,
73+
validatedOptions.backendDir,
74+
log,
75+
);
76+
backendTempDir = tempDir || undefined;
77+
bundleTimer.end();
78+
79+
const frontendAssets = assets.map((asset) => ({
80+
...asset,
81+
relativePath: `frontend/${asset.relativePath}`,
82+
}));
83+
6984
const archiveTimer = log.time('archive assets');
70-
const archive = await createArchive(assets);
85+
const archive = await createArchive([...frontendAssets, ...backendAssets]);
7186
archiveTimer.end();
7287
// Store variable for later disposal of directory.
7388
archiveDir = path.dirname(archive.archivePath);
7489

7590
const uploadTimer = log.time('upload assets');
76-
const {
77-
errors: uploadErrors,
78-
warnings: uploadWarnings,
79-
uploadResponse,
80-
} = await uploadArchive(
91+
const { errors: uploadErrors, warnings: uploadWarnings } = await uploadArchive(
8192
archive,
8293
{
8394
apiKey: context.auth.apiKey,
@@ -105,46 +116,18 @@ Either:
105116
.join('\n - ');
106117
throw new Error(` - ${listOfErrors}`);
107118
}
108-
109-
// After successful upload, publish backend functions to the app definition.
110-
if (uploadResponse && context.auth.apiKey && context.auth.appKey) {
111-
const backendTimer = log.time('publish backend functions');
112-
const { errors: backendErrors, warnings: backendWarnings } =
113-
await publishBackendFunctions(
114-
context.buildRoot,
115-
validatedOptions.backendDir,
116-
uploadResponse.app_builder_id,
117-
{
118-
apiKey: context.auth.apiKey,
119-
appKey: context.auth.appKey,
120-
site: context.auth.site,
121-
},
122-
log,
123-
);
124-
backendTimer.end();
125-
126-
if (backendWarnings.length > 0) {
127-
log.warn(
128-
`${yellow('Warnings while publishing backend functions:')}\n - ${backendWarnings.join('\n - ')}`,
129-
);
130-
}
131-
132-
if (backendErrors.length > 0) {
133-
const listOfErrors = backendErrors
134-
.map((error) => error.cause || error.stack || error.message || error)
135-
.join('\n - ');
136-
throw new Error(` - ${listOfErrors}`);
137-
}
138-
}
139119
} catch (error: any) {
140120
toThrow = error;
141121
log.error(`${red('Failed to upload assets:')}\n${error?.message || error}`);
142122
}
143123

144-
// Clean temporary directory
124+
// Clean temporary directories
145125
if (archiveDir) {
146126
await rm(archiveDir);
147127
}
128+
if (backendTempDir) {
129+
await rm(backendTempDir);
130+
}
148131
handleTimer.end();
149132

150133
if (toThrow) {

packages/plugins/apps/src/upload.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const getOriginHeadersMock = jest.mocked(getOriginHeaders);
4545
describe('Apps Plugin - upload', () => {
4646
const archive = {
4747
archivePath: '/tmp/datadog-apps-assets.zip',
48-
assets: [{ absolutePath: '/tmp/a.js', relativePath: 'a.js' }],
48+
assets: [{ absolutePath: '/tmp/a.js', relativePath: 'frontend/a.js' }],
4949
size: 1234,
5050
};
5151
const context = {

packages/plugins/apps/src/validate.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,12 @@ describe('Apps Plugin - validateOptions', () => {
3939
test('Should set defaults when nothing is provided', () => {
4040
const result = validateOptions({});
4141
expect(result).toEqual({
42+
backendDir: 'backend',
4243
dryRun: true,
4344
enable: false,
4445
include: [],
4546
identifier: undefined,
47+
name: undefined,
4648
});
4749
});
4850

@@ -89,10 +91,12 @@ describe('Apps Plugin - validateOptions', () => {
8991
});
9092

9193
expect(result).toEqual({
94+
backendDir: 'backend',
9295
dryRun: true,
9396
enable: true,
9497
include: ['public/**/*', 'dist/**/*'],
9598
identifier: 'my-app',
99+
name: undefined,
96100
});
97101
});
98102
});

0 commit comments

Comments
 (0)