feat(core): Lazy-load nodes and credentials to reduce baseline memory usage (#4577)

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™
2022-11-23 16:20:28 +01:00
committed by GitHub
parent f63cd3b89e
commit b6c57e19fc
71 changed files with 1102 additions and 1279 deletions

View File

@@ -1,132 +1,108 @@
/* eslint-disable no-underscore-dangle */
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable no-prototype-builtins */
/* eslint-disable no-param-reassign */
/* eslint-disable @typescript-eslint/prefer-optional-chain */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-unsafe-call */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-continue */
/* eslint-disable no-restricted-syntax */
import { CUSTOM_EXTENSION_ENV, UserSettings } from 'n8n-core';
import {
CodexData,
ICredentialType,
ICredentialTypeData,
CUSTOM_EXTENSION_ENV,
UserSettings,
CustomDirectoryLoader,
DirectoryLoader,
PackageDirectoryLoader,
LazyPackageDirectoryLoader,
Types,
} from 'n8n-core';
import type {
ILogger,
INodeType,
INodeTypeData,
INodeTypeNameVersion,
IVersionedNodeType,
LoggerProxy,
jsonParse,
ErrorReporterProxy as ErrorReporter,
INodesAndCredentials,
KnownNodesAndCredentials,
LoadedNodesAndCredentials,
} from 'n8n-workflow';
import { LoggerProxy, ErrorReporterProxy as ErrorReporter } from 'n8n-workflow';
import {
access as fsAccess,
copyFile,
mkdir,
readdir as fsReaddir,
readFile as fsReadFile,
stat as fsStat,
writeFile,
} from 'fs/promises';
import glob from 'fast-glob';
import path from 'path';
import pick from 'lodash.pick';
import { IN8nNodePackageJson } from '@/Interfaces';
import { getLogger } from '@/Logger';
import config from '@/config';
import { NodeTypes } from '@/NodeTypes';
import { InstalledPackages } from '@db/entities/InstalledPackages';
import { InstalledNodes } from '@db/entities/InstalledNodes';
import { executeCommand, loadClassInIsolation } from '@/CommunityNodes/helpers';
import { CLI_DIR, RESPONSE_ERROR_MESSAGES } from '@/constants';
import { executeCommand } from '@/CommunityNodes/helpers';
import { CLI_DIR, GENERATED_STATIC_DIR, RESPONSE_ERROR_MESSAGES } from '@/constants';
import {
persistInstalledPackageData,
removePackageFromDatabase,
} from '@/CommunityNodes/packageModel';
import { CredentialsOverwrites } from '@/CredentialsOverwrites';
const CUSTOM_NODES_CATEGORY = 'Custom Nodes';
export class LoadNodesAndCredentialsClass implements INodesAndCredentials {
known: KnownNodesAndCredentials = { nodes: {}, credentials: {} };
function toJSON() {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...this,
authenticate: typeof this.authenticate === 'function' ? {} : this.authenticate,
};
}
loaded: LoadedNodesAndCredentials = { nodes: {}, credentials: {} };
class LoadNodesAndCredentialsClass {
nodeTypes: INodeTypeData = {};
types: Types = { nodes: [], credentials: [] };
credentialTypes: ICredentialTypeData = {};
excludeNodes = config.getEnv('nodes.exclude');
excludeNodes: string | undefined = undefined;
includeNodes: string | undefined = undefined;
includeNodes = config.getEnv('nodes.include');
logger: ILogger;
async init() {
this.logger = getLogger();
LoggerProxy.init(this.logger);
// Make sure the imported modules can resolve dependencies fine.
const delimiter = process.platform === 'win32' ? ';' : ':';
process.env.NODE_PATH = module.paths.join(delimiter);
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
module.constructor._initPaths();
const nodeModulesPath = await this.getNodeModulesFolderLocation();
this.excludeNodes = config.getEnv('nodes.exclude');
this.includeNodes = config.getEnv('nodes.include');
// Get all the installed packages which contain n8n nodes
const nodePackages = await this.getN8nNodePackages(nodeModulesPath);
for (const packagePath of nodePackages) {
await this.loadDataFromPackage(packagePath);
}
await mkdir(path.join(GENERATED_STATIC_DIR, 'icons/nodes'), { recursive: true });
await mkdir(path.join(GENERATED_STATIC_DIR, 'icons/credentials'), { recursive: true });
await this.loadNodesFromBasePackages();
await this.loadNodesFromDownloadedPackages();
await this.loadNodesFromCustomFolders();
await this.loadNodesFromCustomDirectories();
}
async getNodeModulesFolderLocation(): Promise<string> {
// Get the path to the node-modules folder to be later able
// to load the credentials and nodes
const checkPaths = [
// In case "n8n" package is in same node_modules folder.
path.join(CLI_DIR, '..', 'n8n-workflow'),
// In case "n8n" package is the root and the packages are
// in the "node_modules" folder underneath it.
path.join(CLI_DIR, 'node_modules', 'n8n-workflow'),
// In case "n8n" package is installed using npm/yarn workspaces
// the node_modules folder is in the root of the workspace.
path.join(CLI_DIR, '..', '..', 'node_modules', 'n8n-workflow'),
];
for (const checkPath of checkPaths) {
try {
await fsAccess(checkPath);
// Folder exists, so use it.
return path.dirname(checkPath);
} catch (_) {
// Folder does not exist so get next one
async generateTypesForFrontend() {
const credentialsOverwrites = CredentialsOverwrites().getAll();
for (const credential of this.types.credentials) {
if (credential.name in credentialsOverwrites) {
credential.__overwrittenProperties = Object.keys(credentialsOverwrites[credential.name]);
}
}
throw new Error('Could not find "node_modules" folder!');
// pre-render all the node and credential types as static json files
await mkdir(path.join(GENERATED_STATIC_DIR, 'types'), { recursive: true });
const writeStaticJSON = async (name: string, data: any[]) => {
const filePath = path.join(GENERATED_STATIC_DIR, `types/${name}.json`);
const payload = `[\n${data.map((entry) => JSON.stringify(entry)).join(',\n')}\n]`;
await writeFile(filePath, payload, { encoding: 'utf-8' });
};
await writeStaticJSON('nodes', this.types.nodes);
await writeStaticJSON('credentials', this.types.credentials);
}
async loadNodesFromBasePackages() {
const nodeModulesPath = await this.getNodeModulesPath();
const nodePackagePaths = await this.getN8nNodePackages(nodeModulesPath);
for (const packagePath of nodePackagePaths) {
await this.runDirectoryLoader(LazyPackageDirectoryLoader, packagePath);
}
}
async loadNodesFromDownloadedPackages(): Promise<void> {
const nodePackages = [];
try {
// Read downloaded nodes and credentials
const downloadedNodesFolder = UserSettings.getUserN8nFolderDownloadedNodesPath();
const downloadedNodesFolderModules = path.join(downloadedNodesFolder, 'node_modules');
await fsAccess(downloadedNodesFolderModules);
const downloadedPackages = await this.getN8nNodePackages(downloadedNodesFolderModules);
const downloadedNodesDir = UserSettings.getUserN8nFolderDownloadedNodesPath();
const downloadedNodesDirModules = path.join(downloadedNodesDir, 'node_modules');
await fsAccess(downloadedNodesDirModules);
const downloadedPackages = await this.getN8nNodePackages(downloadedNodesDirModules);
nodePackages.push(...downloadedPackages);
} catch (error) {
// Folder does not exist so ignore and return
@@ -135,15 +111,14 @@ class LoadNodesAndCredentialsClass {
for (const packagePath of nodePackages) {
try {
await this.loadDataFromPackage(packagePath);
// eslint-disable-next-line no-empty
await this.runDirectoryLoader(PackageDirectoryLoader, packagePath);
} catch (error) {
ErrorReporter.error(error);
}
}
}
async loadNodesFromCustomFolders(): Promise<void> {
async loadNodesFromCustomDirectories(): Promise<void> {
// Read nodes and credentials from custom directories
const customDirectories = [];
@@ -158,7 +133,7 @@ class LoadNodesAndCredentialsClass {
}
for (const directory of customDirectories) {
await this.loadDataFromDirectory('CUSTOM', directory);
await this.runDirectoryLoader(CustomDirectoryLoader, directory);
}
}
@@ -192,46 +167,6 @@ class LoadNodesAndCredentialsClass {
return getN8nNodePackagesRecursive('');
}
/**
* Loads credentials from a file
*
* @param {string} credentialName The name of the credentials
* @param {string} filePath The file to read credentials from
*/
loadCredentialsFromFile(credentialName: string, filePath: string): void {
let tempCredential: ICredentialType;
try {
tempCredential = loadClassInIsolation(filePath, credentialName);
// Add serializer method "toJSON" to the class so that authenticate method (if defined)
// gets mapped to the authenticate attribute before it is sent to the client.
// The authenticate property is used by the client to decide whether or not to
// include the credential type in the predefined credentials (HTTP node)
Object.assign(tempCredential, { toJSON });
if (tempCredential.icon && tempCredential.icon.startsWith('file:')) {
// If a file icon gets used add the full path
tempCredential.icon = `file:${path.join(
path.dirname(filePath),
tempCredential.icon.substr(5),
)}`;
}
} catch (e) {
if (e instanceof TypeError) {
throw new Error(
`Class with name "${credentialName}" could not be found. Please check if the class is named correctly!`,
);
} else {
throw e;
}
}
this.credentialTypes[tempCredential.name] = {
type: tempCredential,
sourcePath: filePath,
};
}
async loadNpmModule(packageName: string, version?: string): Promise<InstalledPackages> {
const downloadFolder = UserSettings.getUserN8nFolderDownloadedNodesPath();
const command = `npm install ${packageName}${version ? `@${version}` : ''}`;
@@ -240,24 +175,30 @@ class LoadNodesAndCredentialsClass {
const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName);
const loadedNodes = await this.loadDataFromPackage(finalNodeUnpackedPath);
const { loadedNodes, packageJson } = await this.runDirectoryLoader(
PackageDirectoryLoader,
finalNodeUnpackedPath,
);
if (loadedNodes.length > 0) {
const packageFile = await this.readPackageJson(finalNodeUnpackedPath);
// Save info to DB
try {
const installedPackage = await persistInstalledPackageData(
packageFile.name,
packageFile.version,
packageJson.name,
packageJson.version,
loadedNodes,
this.nodeTypes,
packageFile.author?.name,
packageFile.author?.email,
this.loaded.nodes,
packageJson.author?.name,
packageJson.author?.email,
);
this.attachNodesToNodeTypes(installedPackage.installedNodes);
await this.generateTypesForFrontend();
return installedPackage;
} catch (error) {
LoggerProxy.error('Failed to save installed packages and nodes', { error, packageName });
LoggerProxy.error('Failed to save installed packages and nodes', {
error: error as Error,
packageName,
});
throw error;
}
} else {
@@ -265,9 +206,7 @@ class LoadNodesAndCredentialsClass {
const removeCommand = `npm remove ${packageName}`;
try {
await executeCommand(removeCommand);
} catch (error) {
// Do nothing
}
} catch (_) {}
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES);
}
@@ -278,7 +217,9 @@ class LoadNodesAndCredentialsClass {
await executeCommand(command);
void (await removePackageFromDatabase(installedPackage));
await removePackageFromDatabase(installedPackage);
await this.generateTypesForFrontend();
this.unloadNodes(installedPackage.installedNodes);
}
@@ -294,7 +235,7 @@ class LoadNodesAndCredentialsClass {
try {
await executeCommand(command);
} catch (error) {
if (error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) {
if (error instanceof Error && error.message === RESPONSE_ERROR_MESSAGES.PACKAGE_NOT_FOUND) {
throw new Error(`The npm package "${packageName}" could not be found.`);
}
throw error;
@@ -304,29 +245,35 @@ class LoadNodesAndCredentialsClass {
const finalNodeUnpackedPath = path.join(downloadFolder, 'node_modules', packageName);
const loadedNodes = await this.loadDataFromPackage(finalNodeUnpackedPath);
const { loadedNodes, packageJson } = await this.runDirectoryLoader(
PackageDirectoryLoader,
finalNodeUnpackedPath,
);
if (loadedNodes.length > 0) {
const packageFile = await this.readPackageJson(finalNodeUnpackedPath);
// Save info to DB
try {
await removePackageFromDatabase(installedPackage);
const newlyInstalledPackage = await persistInstalledPackageData(
packageFile.name,
packageFile.version,
packageJson.name,
packageJson.version,
loadedNodes,
this.nodeTypes,
packageFile.author?.name,
packageFile.author?.email,
this.loaded.nodes,
packageJson.author?.name,
packageJson.author?.email,
);
this.attachNodesToNodeTypes(newlyInstalledPackage.installedNodes);
await this.generateTypesForFrontend();
return newlyInstalledPackage;
} catch (error) {
LoggerProxy.error('Failed to save installed packages and nodes', { error, packageName });
LoggerProxy.error('Failed to save installed packages and nodes', {
error: error as Error,
packageName,
});
throw error;
}
} else {
@@ -334,249 +281,119 @@ class LoadNodesAndCredentialsClass {
const removeCommand = `npm remove ${packageName}`;
try {
await executeCommand(removeCommand);
} catch (error) {
// Do nothing
}
} catch (_) {}
throw new Error(RESPONSE_ERROR_MESSAGES.PACKAGE_DOES_NOT_CONTAIN_NODES);
}
}
/**
* Loads a node from a file
*
* @param {string} packageName The package name to set for the found nodes
* @param {string} nodeName Tha name of the node
* @param {string} filePath The file to read node from
*/
loadNodeFromFile(
packageName: string,
nodeName: string,
filePath: string,
): INodeTypeNameVersion | undefined {
let tempNode: INodeType | IVersionedNodeType;
let nodeVersion = 1;
private unloadNodes(installedNodes: InstalledNodes[]): void {
installedNodes.forEach((installedNode) => {
delete this.loaded.nodes[installedNode.type];
});
}
try {
tempNode = loadClassInIsolation(filePath, nodeName);
this.addCodex({ node: tempNode, filePath, isCustom: packageName === 'CUSTOM' });
} catch (error) {
// eslint-disable-next-line no-console, @typescript-eslint/restrict-template-expressions
console.error(`Error loading node "${nodeName}" from: "${filePath}" - ${error.message}`);
throw error;
}
const fullNodeName = `${packageName}.${tempNode.description.name}`;
tempNode.description.name = fullNodeName;
if (tempNode.description.icon !== undefined && tempNode.description.icon.startsWith('file:')) {
// If a file icon gets used add the full path
tempNode.description.icon = `file:${path.join(
path.dirname(filePath),
tempNode.description.icon.substr(5),
)}`;
}
if (tempNode.hasOwnProperty('nodeVersions')) {
const versionedNodeType = (tempNode as IVersionedNodeType).getNodeType();
this.addCodex({ node: versionedNodeType, filePath, isCustom: packageName === 'CUSTOM' });
nodeVersion = (tempNode as IVersionedNodeType).currentVersion;
if (
versionedNodeType.description.icon !== undefined &&
versionedNodeType.description.icon.startsWith('file:')
) {
// If a file icon gets used add the full path
versionedNodeType.description.icon = `file:${path.join(
path.dirname(filePath),
versionedNodeType.description.icon.substr(5),
)}`;
}
if (versionedNodeType.hasOwnProperty('executeSingle')) {
this.logger.warn(
`"executeSingle" will get deprecated soon. Please update the code of node "${packageName}.${nodeName}" to use "execute" instead!`,
{ filePath },
);
}
} else {
// Short renaming to avoid type issues
const tmpNode = tempNode as INodeType;
nodeVersion = Array.isArray(tmpNode.description.version)
? tmpNode.description.version.slice(-1)[0]
: tmpNode.description.version;
}
if (this.includeNodes !== undefined && !this.includeNodes.includes(fullNodeName)) {
return;
}
// Check if the node should be skipped
if (this.excludeNodes !== undefined && this.excludeNodes.includes(fullNodeName)) {
return;
}
this.nodeTypes[fullNodeName] = {
type: tempNode,
sourcePath: filePath,
};
// eslint-disable-next-line consistent-return
return {
name: fullNodeName,
version: nodeVersion,
} as INodeTypeNameVersion;
private attachNodesToNodeTypes(installedNodes: InstalledNodes[]): void {
const loadedNodes = this.loaded.nodes;
installedNodes.forEach((installedNode) => {
const { type, sourcePath } = loadedNodes[installedNode.type];
loadedNodes[installedNode.type] = { type, sourcePath };
});
}
/**
* Retrieves `categories`, `subcategories`, partial `resources` and
* alias (if defined) from the codex data for the node at the given file path.
*
* @param {string} filePath The file path to a `*.node.js` file
* Run a loader of source files of nodes and credentials in a directory.
*/
getCodex(filePath: string): CodexData {
// eslint-disable-next-line global-require, import/no-dynamic-require, @typescript-eslint/no-var-requires
const { categories, subcategories, resources: allResources, alias } = require(`${filePath}on`); // .js to .json
private async runDirectoryLoader<T extends DirectoryLoader>(
constructor: new (...args: ConstructorParameters<typeof DirectoryLoader>) => T,
dir: string,
) {
const loader = new constructor(dir, this.excludeNodes, this.includeNodes);
await loader.loadAll();
const resources = pick(allResources, ['primaryDocumentation', 'credentialDocumentation']);
// list of node & credential types that will be sent to the frontend
const { types } = loader;
this.types.nodes = this.types.nodes.concat(types.nodes);
this.types.credentials = this.types.credentials.concat(types.credentials);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...(categories && { categories }),
...(subcategories && { subcategories }),
...(resources && { resources }),
...(alias && { alias }),
};
}
/**
* Adds a node codex `categories` and `subcategories` (if defined)
* to a node description `codex` property.
*
* @param obj.node Node to add categories to
* @param obj.filePath Path to the built node
* @param obj.isCustom Whether the node is custom
*/
addCodex({
node,
filePath,
isCustom,
}: {
node: INodeType | IVersionedNodeType;
filePath: string;
isCustom: boolean;
}) {
try {
const codex = this.getCodex(filePath);
if (isCustom) {
codex.categories = codex.categories
? codex.categories.concat(CUSTOM_NODES_CATEGORY)
: [CUSTOM_NODES_CATEGORY];
// Copy over all icons and set `iconUrl` for the frontend
const iconPromises: Array<Promise<void>> = [];
for (const node of types.nodes) {
if (node.icon?.startsWith('file:')) {
const icon = node.icon.substring(5);
const iconUrl = `icons/nodes/${node.name}${path.extname(icon)}`;
delete node.icon;
node.iconUrl = iconUrl;
iconPromises.push(copyFile(path.join(dir, icon), path.join(GENERATED_STATIC_DIR, iconUrl)));
}
}
for (const credential of types.credentials) {
if (credential.icon?.startsWith('file:')) {
const icon = credential.icon.substring(5);
const iconUrl = `icons/credentials/${credential.name}${path.extname(icon)}`;
delete credential.icon;
credential.iconUrl = iconUrl;
iconPromises.push(copyFile(path.join(dir, icon), path.join(GENERATED_STATIC_DIR, iconUrl)));
}
}
await Promise.all(iconPromises);
node.description.codex = codex;
} catch (_) {
this.logger.debug(`No codex available for: ${filePath.split('/').pop() ?? ''}`);
// Nodes and credentials that have been loaded immediately
for (const nodeTypeName in loader.nodeTypes) {
this.loaded.nodes[nodeTypeName] = loader.nodeTypes[nodeTypeName];
}
if (isCustom) {
node.description.codex = {
categories: [CUSTOM_NODES_CATEGORY],
for (const credentialTypeName in loader.credentialTypes) {
this.loaded.credentials[credentialTypeName] = loader.credentialTypes[credentialTypeName];
}
// Nodes and credentials that will be lazy loaded
if (loader instanceof LazyPackageDirectoryLoader) {
const { packageName, known } = loader;
for (const type in known.nodes) {
const { className, sourcePath } = known.nodes[type];
this.known.nodes[`${packageName}.${type}`] = {
className,
sourcePath: path.join(dir, sourcePath),
};
}
}
}
/**
* Loads nodes and credentials from the given directory
*
* @param {string} setPackageName The package name to set for the found nodes
* @param {string} directory The directory to look in
*/
async loadDataFromDirectory(setPackageName: string, directory: string): Promise<void> {
const files = await glob('**/*.@(node|credentials).js', {
cwd: directory,
absolute: true,
});
for (const filePath of files) {
const [fileName, type] = path.parse(filePath).name.split('.');
if (type === 'node') {
this.loadNodeFromFile(setPackageName, fileName, filePath);
} else if (type === 'credentials') {
this.loadCredentialsFromFile(fileName, filePath);
}
}
}
async readPackageJson(packagePath: string): Promise<IN8nNodePackageJson> {
// Get the absolute path of the package
const packageFileString = await fsReadFile(path.join(packagePath, 'package.json'), 'utf8');
return jsonParse(packageFileString);
}
/**
* Loads nodes and credentials from the package with the given name
*
* @param {string} packagePath The path to read data from
*/
async loadDataFromPackage(packagePath: string): Promise<INodeTypeNameVersion[]> {
// Get the absolute path of the package
const packageFile = await this.readPackageJson(packagePath);
if (!packageFile.n8n) {
return [];
}
const packageName = packageFile.name;
const { nodes, credentials } = packageFile.n8n;
const returnData: INodeTypeNameVersion[] = [];
// Read all node types
if (Array.isArray(nodes)) {
for (const filePath of nodes) {
const tempPath = path.join(packagePath, filePath);
const [fileName] = path.parse(filePath).name.split('.');
const loadData = this.loadNodeFromFile(packageName, fileName, tempPath);
if (loadData) {
returnData.push(loadData);
}
for (const type in known.credentials) {
const { className, sourcePath } = known.credentials[type];
this.known.credentials[type] = { className, sourcePath: path.join(dir, sourcePath) };
}
}
// Read all credential types
if (Array.isArray(credentials)) {
for (const filePath of credentials) {
const tempPath = path.join(packagePath, filePath);
const [fileName] = path.parse(filePath).name.split('.');
this.loadCredentialsFromFile(fileName, tempPath);
}
return loader;
}
private async getNodeModulesPath(): Promise<string> {
// Get the path to the node-modules folder to be later able
// to load the credentials and nodes
const checkPaths = [
// In case "n8n" package is in same node_modules folder.
path.join(CLI_DIR, '..', 'n8n-workflow'),
// In case "n8n" package is the root and the packages are
// in the "node_modules" folder underneath it.
path.join(CLI_DIR, 'node_modules', 'n8n-workflow'),
// In case "n8n" package is installed using npm/yarn workspaces
// the node_modules folder is in the root of the workspace.
path.join(CLI_DIR, '..', '..', 'node_modules', 'n8n-workflow'),
];
for (const checkPath of checkPaths) {
try {
await fsAccess(checkPath);
// Folder exists, so use it.
return path.dirname(checkPath);
} catch (_) {} // Folder does not exist so get next one
}
return returnData;
}
unloadNodes(installedNodes: InstalledNodes[]): void {
const nodeTypes = NodeTypes();
installedNodes.forEach((installedNode) => {
nodeTypes.removeNodeType(installedNode.type);
delete this.nodeTypes[installedNode.type];
});
}
attachNodesToNodeTypes(installedNodes: InstalledNodes[]): void {
const nodeTypes = NodeTypes();
installedNodes.forEach((installedNode) => {
nodeTypes.attachNodeType(
installedNode.type,
this.nodeTypes[installedNode.type].type,
this.nodeTypes[installedNode.type].sourcePath,
);
});
throw new Error('Could not find "node_modules" folder!');
}
}
let packagesInformationInstance: LoadNodesAndCredentialsClass | undefined;
// eslint-disable-next-line @typescript-eslint/naming-convention
export function LoadNodesAndCredentials(): LoadNodesAndCredentialsClass {
if (packagesInformationInstance === undefined) {
packagesInformationInstance = new LoadNodesAndCredentialsClass();