n8n/packages/core/src/DirectoryLoader.ts

408 lines
11 KiB
TypeScript

import * as path from 'path';
import { readFile } from 'fs/promises';
import glob from 'fast-glob';
import { jsonParse, getVersionedNodeTypeAll, LoggerProxy as Logger } from 'n8n-workflow';
import type {
CodexData,
DocumentationLink,
ICredentialType,
ICredentialTypeData,
INodeType,
INodeTypeBaseDescription,
INodeTypeDescription,
INodeTypeData,
INodeTypeNameVersion,
IVersionedNodeType,
KnownNodesAndCredentials,
} from 'n8n-workflow';
import { CUSTOM_NODES_CATEGORY } from './Constants';
import type { n8n } from './Interfaces';
import { loadClassInIsolation } from './ClassLoader';
function toJSON(this: ICredentialType) {
return {
...this,
authenticate: typeof this.authenticate === 'function' ? {} : this.authenticate,
};
}
export type Types = {
nodes: INodeTypeBaseDescription[];
credentials: ICredentialType[];
};
export abstract class DirectoryLoader {
isLazyLoaded = false;
loadedNodes: INodeTypeNameVersion[] = [];
nodeTypes: INodeTypeData = {};
credentialTypes: ICredentialTypeData = {};
known: KnownNodesAndCredentials = { nodes: {}, credentials: {} };
types: Types = { nodes: [], credentials: [] };
withLoadOptionsMethods = false; // only for validation during build
constructor(
readonly directory: string,
protected readonly excludeNodes: string[] = [],
protected readonly includeNodes: string[] = [],
) {}
abstract packageName: string;
abstract loadAll(): Promise<void>;
reset() {
this.loadedNodes = [];
this.nodeTypes = {};
this.credentialTypes = {};
this.known = { nodes: {}, credentials: {} };
this.types = { nodes: [], credentials: [] };
}
protected resolvePath(file: string) {
return path.resolve(this.directory, file);
}
protected loadNodeFromFile(nodeName: string, filePath: string) {
let tempNode: INodeType | IVersionedNodeType;
let nodeVersion = 1;
const isCustom = this.packageName === 'CUSTOM';
try {
tempNode = loadClassInIsolation(filePath, nodeName);
this.addCodex({ node: tempNode, filePath, isCustom });
} catch (error) {
Logger.error(
`Error loading node "${nodeName}" from: "${filePath}" - ${(error as Error).message}`,
);
throw error;
}
const fullNodeName = `${this.packageName}.${tempNode.description.name}`;
if (this.includeNodes.length && !this.includeNodes.includes(fullNodeName)) {
return;
}
if (this.excludeNodes.includes(fullNodeName)) {
return;
}
tempNode.description.name = fullNodeName;
this.fixIconPath(tempNode.description, filePath);
if ('nodeVersions' in tempNode) {
for (const versionNode of Object.values(tempNode.nodeVersions)) {
this.fixIconPath(versionNode.description, filePath);
}
const currentVersionNode = tempNode.nodeVersions[tempNode.currentVersion];
this.addCodex({ node: currentVersionNode, filePath, isCustom });
nodeVersion = tempNode.currentVersion;
if (this.withLoadOptionsMethods) this.addLoadOptionsMethods(currentVersionNode);
if (currentVersionNode.hasOwnProperty('executeSingle')) {
Logger.warn(
`"executeSingle" will get deprecated soon. Please update the code of node "${this.packageName}.${nodeName}" to use "execute" instead!`,
{ filePath },
);
}
} else {
if (this.withLoadOptionsMethods) this.addLoadOptionsMethods(tempNode);
// Short renaming to avoid type issues
nodeVersion = Array.isArray(tempNode.description.version)
? tempNode.description.version.slice(-1)[0]
: tempNode.description.version;
}
this.known.nodes[fullNodeName] = {
className: nodeName,
sourcePath: filePath,
};
this.nodeTypes[fullNodeName] = {
type: tempNode,
sourcePath: filePath,
};
this.loadedNodes.push({
name: fullNodeName,
version: nodeVersion,
});
getVersionedNodeTypeAll(tempNode).forEach(({ description }) => {
this.types.nodes.push(description);
});
}
protected loadCredentialFromFile(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 });
this.fixIconPath(tempCredential, filePath);
} 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.known.credentials[tempCredential.name] = {
className: credentialName,
sourcePath: filePath,
};
this.credentialTypes[tempCredential.name] = {
type: tempCredential,
sourcePath: filePath,
};
this.types.credentials.push(tempCredential);
}
/**
* Retrieves `categories`, `subcategories` and alias (if defined)
* from the codex data for the node at the given file path.
*/
private getCodex(filePath: string): CodexData {
type Codex = {
categories: string[];
subcategories: { [subcategory: string]: string[] };
resources: {
primaryDocumentation: DocumentationLink[];
credentialDocumentation: DocumentationLink[];
};
alias: string[];
};
const codexFilePath = `${filePath}on`; // .js to .json
const {
categories,
subcategories,
resources: allResources,
alias,
} = module.require(codexFilePath) as Codex;
const resources = {
primaryDocumentation: allResources.primaryDocumentation,
credentialDocumentation: allResources.credentialDocumentation,
};
return {
...(categories && { categories }),
...(subcategories && { subcategories }),
...(resources && { resources }),
...(alias && { alias }),
};
}
/**
* Adds a node codex `categories` and `subcategories` (if defined)
* to a node description `codex` property.
*/
private 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];
}
node.description.codex = codex;
} catch {
Logger.debug(`No codex available for: ${filePath.split('/').pop() ?? ''}`);
if (isCustom) {
node.description.codex = {
categories: [CUSTOM_NODES_CATEGORY],
};
}
}
}
private addLoadOptionsMethods(node: INodeType) {
if (node?.methods?.loadOptions) {
node.description.__loadOptionsMethods = Object.keys(node.methods.loadOptions);
}
}
private fixIconPath(
obj: INodeTypeDescription | INodeTypeBaseDescription | ICredentialType,
filePath: string,
) {
if (obj.icon?.startsWith('file:')) {
const iconPath = path.join(path.dirname(filePath), obj.icon.substring(5));
const relativePath = path.relative(this.directory, iconPath);
obj.iconUrl = `icons/${this.packageName}/${relativePath}`;
delete obj.icon;
}
}
}
/**
* Loader for source files of nodes and credentials located in a custom dir,
* e.g. `~/.n8n/custom`
*/
export class CustomDirectoryLoader extends DirectoryLoader {
packageName = 'CUSTOM';
override async loadAll() {
const filePaths = await glob('**/*.@(node|credentials).js', {
cwd: this.directory,
absolute: true,
});
for (const filePath of filePaths) {
const [fileName, type] = path.parse(filePath).name.split('.');
if (type === 'node') {
this.loadNodeFromFile(fileName, filePath);
} else if (type === 'credentials') {
this.loadCredentialFromFile(fileName, filePath);
}
}
}
}
/**
* Loader for source files of nodes and credentials located in a package dir,
* e.g. /nodes-base or community packages.
*/
export class PackageDirectoryLoader extends DirectoryLoader {
packageName = '';
packageJson!: n8n.PackageJson;
async readPackageJson() {
this.packageJson = await this.readJSON('package.json');
this.packageName = this.packageJson.name;
}
override async loadAll(options = { withLoadOptionsMethods: false }) {
this.withLoadOptionsMethods = options.withLoadOptionsMethods;
await this.readPackageJson();
const { n8n } = this.packageJson;
if (!n8n) return;
const { nodes, credentials } = n8n;
if (Array.isArray(credentials)) {
for (const credential of credentials) {
const filePath = this.resolvePath(credential);
const [credentialName] = path.parse(credential).name.split('.');
this.loadCredentialFromFile(credentialName, filePath);
}
}
if (Array.isArray(nodes)) {
for (const node of nodes) {
const filePath = this.resolvePath(node);
const [nodeName] = path.parse(node).name.split('.');
this.loadNodeFromFile(nodeName, filePath);
}
}
Logger.debug(`Loaded all credentials and nodes from ${this.packageName}`, {
credentials: credentials?.length ?? 0,
nodes: nodes?.length ?? 0,
});
}
protected async readJSON<T>(file: string): Promise<T> {
const filePath = this.resolvePath(file);
const fileString = await readFile(filePath, 'utf8');
try {
return jsonParse<T>(fileString);
} catch (error) {
throw new Error(`Failed to parse JSON from ${filePath}`);
}
}
}
/**
* This loader extends PackageDirectoryLoader to load node and credentials lazily, if possible
*/
export class LazyPackageDirectoryLoader extends PackageDirectoryLoader {
override async loadAll() {
await this.readPackageJson();
try {
const knownNodes: typeof this.known.nodes = await this.readJSON('dist/known/nodes.json');
for (const nodeName in knownNodes) {
this.known.nodes[`${this.packageName}.${nodeName}`] = knownNodes[nodeName];
}
this.known.credentials = await this.readJSON('dist/known/credentials.json');
this.types.nodes = await this.readJSON('dist/types/nodes.json');
this.types.credentials = await this.readJSON('dist/types/credentials.json');
if (this.includeNodes.length) {
const allowedNodes: typeof this.known.nodes = {};
for (const nodeName of this.includeNodes) {
allowedNodes[nodeName] = this.known.nodes[nodeName];
}
this.known.nodes = allowedNodes;
this.types.nodes = this.types.nodes.filter((nodeType) =>
this.includeNodes.includes(nodeType.name),
);
}
if (this.excludeNodes.length) {
for (const nodeName of this.excludeNodes) {
delete this.known.nodes[nodeName];
}
this.types.nodes = this.types.nodes.filter(
(nodeType) => !this.excludeNodes.includes(nodeType.name),
);
}
Logger.debug(`Lazy Loading credentials and nodes from ${this.packageJson.name}`, {
credentials: this.types.credentials?.length ?? 0,
nodes: this.types.nodes?.length ?? 0,
});
this.isLazyLoaded = true;
return; // We can load nodes and credentials lazily now
} catch {
Logger.debug("Can't enable lazy-loading");
await super.loadAll();
}
}
}