Skip to content
Merged
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
2 changes: 2 additions & 0 deletions packages/plugins/apps/src/backend/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export interface BackendFunction {
name: string;
/** Absolute path to the .backend.ts source file */
absolutePath: string;
/** Connection IDs this backend function is allowed to use. */
allowedConnectionIds: string[];
}

/**
Expand Down
124 changes: 114 additions & 10 deletions packages/plugins/apps/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
mockLogFn,
} from '@dd/tests/_jest/helpers/mocks';
import { runBundlers } from '@dd/tests/_jest/helpers/runBundlers';
import fsp from 'fs/promises';
import nock from 'nock';
import path from 'path';

Expand Down Expand Up @@ -149,10 +150,18 @@ describe('Apps Plugin - getPlugins', () => {
];
jest.spyOn(assets, 'collectAssets').mockResolvedValue(mockedAssets);
jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined);
jest.spyOn(archive, 'createArchive').mockResolvedValue({
archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip',
assets: mockedAssets,
size: 10,
let manifest: unknown;
jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => {
const manifestAsset = archiveAssets.find(
(asset) => asset.relativePath === 'manifest.json',
);
expect(manifestAsset).toBeDefined();
manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8'));
return {
archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip',
assets: archiveAssets,
size: 10,
};
});
jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({
errors: [],
Expand All @@ -163,12 +172,18 @@ describe('Apps Plugin - getPlugins', () => {
await closeBundle();

expect(assets.collectAssets).toHaveBeenCalledWith(['dist/**/*'], buildRoot);
expect(archive.createArchive).toHaveBeenCalledWith([
{
absolutePath: '/project/dist/index.js',
relativePath: path.join('frontend', 'dist/index.js'),
},
]);
expect(archive.createArchive).toHaveBeenCalledWith(
expect.arrayContaining([
{
absolutePath: '/project/dist/index.js',
relativePath: path.join('frontend', 'dist/index.js'),
},
expect.objectContaining({
relativePath: 'manifest.json',
}),
]),
);
expect(manifest).toEqual({ backend: { functions: {} } });
expect(uploader.uploadArchive).toHaveBeenCalledWith(
expect.objectContaining({ archivePath: '/tmp/dd-apps-123/datadog-apps-assets.zip' }),
{
Expand All @@ -188,6 +203,94 @@ describe('Apps Plugin - getPlugins', () => {
'warn',
);
expect(fsHelpers.rm).toHaveBeenCalledWith(path.resolve('/tmp/dd-apps-123'));
expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-'));
});

test('Should emit root manifest.json with backend function connection allowlists', async () => {
jest.spyOn(identifier, 'resolveIdentifier').mockReturnValue({
identifier: 'repo:app',
name: 'test-app',
});
jest.spyOn(assets, 'collectAssets').mockResolvedValue([
{ absolutePath: '/project/dist/index.js', relativePath: 'dist/index.js' },
]);
jest.spyOn(fsHelpers, 'rm').mockResolvedValue(undefined);
jest.spyOn(uploader, 'uploadArchive').mockResolvedValue({
errors: [],
warnings: [],
});

let manifest: unknown;
jest.spyOn(archive, 'createArchive').mockImplementation(async (archiveAssets) => {
const manifestAsset = archiveAssets.find(
(asset) => asset.relativePath === 'manifest.json',
);
expect(manifestAsset).toBeDefined();
manifest = JSON.parse(await fsp.readFile(manifestAsset!.absolutePath, 'utf8'));
return {
archivePath: '/tmp/dd-apps-789/datadog-apps-assets.zip',
assets: archiveAssets,
size: 30,
};
});

const viteBuild = jest.fn().mockResolvedValue({
output: [
{
type: 'chunk',
isEntry: true,
name: expect.any(String),
fileName: 'unused.greet.js',
},
],
});
const args = getArgs();
args.bundler = { build: viteBuild };
const plugins = getPlugins(args);
const transform = plugins[0].transform as {
handler: (code: string, id: string) => unknown;
};
transform.handler.call(
{
parse: () => ({
type: 'Program',
body: [
{
type: 'ExportNamedDeclaration',
declaration: {
type: 'FunctionDeclaration',
id: { type: 'Identifier', name: 'greet' },
},
specifiers: [],
},
],
}),
},
'export function greet() {}',
'/project/src/backend/greet.backend.js',
);

await extractCloseBundle(plugins)();

expect(archive.createArchive).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({ relativePath: 'manifest.json' }),
expect.objectContaining({
relativePath: expect.stringMatching(/^backend\/.*\.greet\.js$/),
}),
]),
);
expect(
Object.keys((manifest as { backend: { functions: object } }).backend.functions),
).toEqual([expect.stringMatching(/^[a-f0-9]{64}\.greet$/)]);
expect(manifest).toMatchObject({
backend: { functions: expect.any(Object) },
});
expect(
Object.values(
(manifest as { backend: { functions: Record<string, unknown> } }).backend.functions,
),
).toEqual([{ allowedConnectionIds: [] }]);
});

test('Should surface upload errors', async () => {
Expand Down Expand Up @@ -215,6 +318,7 @@ describe('Apps Plugin - getPlugins', () => {

expect(mockLogFn).toHaveBeenCalledWith(expect.stringContaining('upload failed'), 'error');
expect(fsHelpers.rm).toHaveBeenCalledWith(path.resolve('/tmp/dd-apps-456'));
expect(fsHelpers.rm).toHaveBeenCalledWith(expect.stringContaining('dd-apps-manifest-'));
});

test('Should upload assets with vite bundler', async () => {
Expand Down
52 changes: 50 additions & 2 deletions packages/plugins/apps/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { rm } from '@dd/core/helpers/fs';
import type { GetPlugins } from '@dd/core/types';
import { InjectPosition } from '@dd/core/types';
import chalk from 'chalk';
import fsp from 'fs/promises';
import os from 'os';
import path from 'path';

import { createArchive } from './archive';
Expand All @@ -17,7 +19,7 @@ import { encodeQueryName } from './backend/encodeQueryName';
import { generateProxyModule } from './backend/proxy-codegen';
import { BACKEND_FILE_RE, CONFIG_KEY, PLUGIN_NAME } from './constants';
import { resolveIdentifier } from './identifier';
import type { AppsOptions } from './types';
import type { AppsManifest, AppsOptions } from './types';
import { uploadArchive } from './upload';
import { validateOptions } from './validate';
import { getVitePlugin } from './vite/index';
Expand All @@ -40,7 +42,12 @@ function buildProxyModule(
const proxyExports: Array<{ exportName: string; queryName: string }> = [];

for (const exportName of exportNames) {
const func = { relativePath: refPath, name: exportName, absolutePath: id };
const func = {
relativePath: refPath,
name: exportName,
absolutePath: id,
allowedConnectionIds: [],
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will populate this value in a later PR.

};
functions.push(func);
proxyExports.push({ exportName, queryName: encodeQueryName(func) });
}
Expand All @@ -50,6 +57,7 @@ function buildProxyModule(

const yellow = chalk.yellow.bold;
const red = chalk.red.bold;
const MANIFEST_FILE_NAME = 'manifest.json';

/**
* Create a registry for tracking discovered backend functions.
Expand All @@ -71,6 +79,37 @@ function createBackendFunctionRegistry() {
};
}

function buildManifest(backendFunctions: BackendFunction[]): AppsManifest {
const functions: AppsManifest['backend']['functions'] = {};
for (const func of backendFunctions) {
functions[encodeQueryName(func)] = {
allowedConnectionIds: [...func.allowedConnectionIds],
};
}
return { backend: { functions } };
}

async function writeManifestFile(backendFunctions: BackendFunction[]): Promise<{
manifestAsset: Asset;
cleanup: () => Promise<void>;
}> {
const manifestDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'dd-apps-manifest-'));
const manifestPath = path.join(manifestDir, MANIFEST_FILE_NAME);
try {
await fsp.writeFile(manifestPath, JSON.stringify(buildManifest(backendFunctions), null, 2));
} catch (error) {
await rm(manifestDir);
throw error;
}
return {
manifestAsset: {
absolutePath: manifestPath,
relativePath: MANIFEST_FILE_NAME,
},
cleanup: () => rm(manifestDir),
};
}

export type types = {
// Add the types you'd like to expose here.
AppsOptions: AppsOptions;
Expand Down Expand Up @@ -109,6 +148,7 @@ export const getPlugins: GetPlugins = ({ options, context, bundler }) => {
const handleUpload = async (backendOutputs: Map<string, string>) => {
const handleTimer = log.time('handle assets');
let archiveDir: string | undefined;
let cleanupManifest: (() => Promise<void>) | undefined;
try {
const identifierTimer = log.time('resolve identifier');

Expand Down Expand Up @@ -158,6 +198,11 @@ Either:
});
}

const backendFunctions = getBackendFunctions();
const { manifestAsset, cleanup } = await writeManifestFile(backendFunctions);
cleanupManifest = cleanup;
allAssets.push(manifestAsset);

const archiveTimer = log.time('archive assets');
const archive = await createArchive(allAssets);
archiveTimer.end();
Expand Down Expand Up @@ -202,6 +247,9 @@ Either:
if (archiveDir) {
await rm(archiveDir);
}
if (cleanupManifest) {
await cleanupManifest();
}
handleTimer.end();

if (toThrow) {
Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/apps/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,17 @@ export type AppsOptions = {
name?: string;
};

export type AppsManifest = {
backend: {
/** Mapping of encoded query name to information about that backend function. */
functions: Record<
string,
{
allowedConnectionIds: string[];
}
>;
};
};

// We don't enforce identifier, as it needs to be dynamically computed if absent.
export type AppsOptionsWithDefaults = WithRequired<AppsOptions, 'enable' | 'include' | 'dryRun'>;
Loading
Loading