mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 11:35:03 +02:00
743 lines
23 KiB
TypeScript
743 lines
23 KiB
TypeScript
import { inTest, isContainedWithin, Logger, ModuleRegistry } from '@n8n/backend-common';
|
|
import { GlobalConfig } from '@n8n/config';
|
|
import { Container, Service } from '@n8n/di';
|
|
import { isWindowsFilePath } from '@n8n/utils/files/is-windows-file-path';
|
|
import type ParcelWatcher from '@parcel/watcher';
|
|
import glob from 'fast-glob';
|
|
import fsPromises from 'fs/promises';
|
|
import type { Class, OutputSchemaLookup, Types } from 'n8n-core';
|
|
import {
|
|
CUSTOM_EXTENSION_ENV,
|
|
DirectoryLoader,
|
|
ErrorReporter,
|
|
InstanceSettings,
|
|
CustomDirectoryLoader,
|
|
PackageDirectoryLoader,
|
|
LazyPackageDirectoryLoader,
|
|
UnrecognizedCredentialTypeError,
|
|
UnrecognizedNodeTypeError,
|
|
ExecutionContextHookRegistry,
|
|
CUSTOM_NODES_PACKAGE_NAME,
|
|
resolveOutputSchemaPath,
|
|
loadOutputSchema,
|
|
OUTPUT_PARSER_SCHEMA_VARIANT,
|
|
} from 'n8n-core';
|
|
import type {
|
|
KnownNodesAndCredentials,
|
|
INodeTypeDescription,
|
|
LoadedClass,
|
|
ICredentialType,
|
|
INodeType,
|
|
IVersionedNodeType,
|
|
INodeProperties,
|
|
LoadedNodesAndCredentials,
|
|
NodeLoader,
|
|
} from 'n8n-workflow';
|
|
import { ensureError } from '@n8n/utils/errors/ensure-error';
|
|
import { injectDomainRestrictionFields, UnexpectedError, UserError } from 'n8n-workflow';
|
|
import path from 'path';
|
|
import picocolors from 'picocolors';
|
|
|
|
import { CUSTOM_API_CALL_KEY, CUSTOM_API_CALL_NAME, CLI_DIR, inE2ETests } from '@/constants';
|
|
import { createAiTools, createHitlTools } from '@/tool-generation';
|
|
|
|
@Service()
|
|
export class LoadNodesAndCredentials {
|
|
private known: KnownNodesAndCredentials = { nodes: {}, credentials: {} };
|
|
|
|
// This contains the actually loaded objects, and their source paths
|
|
loaded: LoadedNodesAndCredentials = { nodes: {}, credentials: {} };
|
|
|
|
// For nodes, this only contains the descriptions, loaded from either the
|
|
// actual file, or the lazy loaded json
|
|
types: Types = { nodes: [], credentials: [] };
|
|
|
|
loaders: Record<string, NodeLoader> = {};
|
|
|
|
excludeNodes = this.globalConfig.nodes.exclude;
|
|
|
|
includeNodes = this.globalConfig.nodes.include;
|
|
|
|
private postProcessors: Array<() => Promise<void>> = [];
|
|
|
|
constructor(
|
|
private readonly logger: Logger,
|
|
private readonly errorReporter: ErrorReporter,
|
|
private readonly instanceSettings: InstanceSettings,
|
|
private readonly globalConfig: GlobalConfig,
|
|
private readonly moduleRegistry: ModuleRegistry,
|
|
private readonly executionContextHookRegistry: ExecutionContextHookRegistry,
|
|
) {}
|
|
|
|
async init() {
|
|
if (inTest) throw new UnexpectedError('Not available in tests');
|
|
|
|
// Make sure the imported modules can resolve dependencies fine.
|
|
// Preserve any existing NODE_PATH (e.g. set by Docker ENV for global npm packages)
|
|
// so that the task runner subprocess can also resolve externally installed modules.
|
|
const delimiter = process.platform === 'win32' ? ';' : ':';
|
|
process.env.NODE_PATH = [module.paths.join(delimiter), process.env.NODE_PATH]
|
|
.filter(Boolean)
|
|
.join(delimiter);
|
|
|
|
// @ts-ignore
|
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
|
module.constructor._initPaths();
|
|
|
|
if (!inE2ETests) {
|
|
this.excludeNodes = this.excludeNodes ?? [];
|
|
this.excludeNodes.push('n8n-nodes-base.e2eTest');
|
|
}
|
|
|
|
if (process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS !== 'true') {
|
|
this.excludeNodes = this.excludeNodes ?? [];
|
|
this.excludeNodes.push('n8n-nodes-base.dynamicCredentialCheck');
|
|
}
|
|
|
|
// Load nodes from `n8n-nodes-base`
|
|
const basePathsToScan = [
|
|
// In case "n8n" package is in same node_modules folder.
|
|
path.join(CLI_DIR, '..'),
|
|
// In case "n8n" package is the root and the packages are
|
|
// in the "node_modules" folder underneath it.
|
|
path.join(CLI_DIR, 'node_modules'),
|
|
];
|
|
|
|
for (const nodeModulesDir of basePathsToScan) {
|
|
await this.loadNodesFromNodeModules(nodeModulesDir, 'n8n-nodes-base');
|
|
await this.loadNodesFromNodeModules(nodeModulesDir, '@n8n/n8n-nodes-langchain');
|
|
}
|
|
|
|
await this.loadNodesFromCustomDirectories();
|
|
|
|
for (const loader of this.moduleRegistry.nodeLoaders) {
|
|
if (loader.packageName in this.loaders) {
|
|
throw new UnexpectedError(
|
|
picocolors.red(`Node loader ${loader.packageName} is already registered.`),
|
|
);
|
|
}
|
|
try {
|
|
await loader.loadAll();
|
|
this.loaders[loader.packageName] = loader;
|
|
} catch (error) {
|
|
this.logger.error(`Failed to load package "${loader.packageName}"`, {
|
|
error: ensureError(error),
|
|
});
|
|
this.errorReporter.error(error, { extra: { packageName: loader.packageName } });
|
|
}
|
|
}
|
|
|
|
await this.postProcessLoaders();
|
|
}
|
|
|
|
addPostProcessor(fn: () => Promise<void>) {
|
|
this.postProcessors.push(fn);
|
|
}
|
|
|
|
releaseTypes() {
|
|
this.types = { nodes: [], credentials: [] };
|
|
for (const loader of Object.values(this.loaders)) {
|
|
loader.releaseTypes();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the current node and credential types.
|
|
* If types have been released from memory, re-runs postProcessLoaders to
|
|
* repopulate them first, then releases after snapshotting.
|
|
*
|
|
* WARNING: Holding types in memory is very consuming. Use sparingly and only
|
|
* where the caller genuinely needs its own copy (e.g. the AI workflow builder
|
|
* service or the frontend service writing static JSON files).
|
|
*/
|
|
async collectTypes(): Promise<Types> {
|
|
const needsReload = this.types.nodes.length === 0 && this.types.credentials.length === 0;
|
|
if (needsReload) {
|
|
await this.postProcessLoaders();
|
|
}
|
|
const types: Types = {
|
|
nodes: this.types.nodes,
|
|
credentials: this.types.credentials,
|
|
};
|
|
if (needsReload) {
|
|
this.releaseTypes();
|
|
}
|
|
return types;
|
|
}
|
|
|
|
isKnownNode(type: string) {
|
|
return type in this.known.nodes;
|
|
}
|
|
|
|
get loadedCredentials() {
|
|
return this.loaded.credentials;
|
|
}
|
|
|
|
get loadedNodes() {
|
|
return this.loaded.nodes;
|
|
}
|
|
|
|
get knownCredentials() {
|
|
return this.known.credentials;
|
|
}
|
|
|
|
get knownNodes() {
|
|
return this.known.nodes;
|
|
}
|
|
|
|
private async loadNodesFromNodeModules(
|
|
nodeModulesDir: string,
|
|
packageName: string,
|
|
): Promise<void> {
|
|
const installedPackagePaths = await glob(packageName, {
|
|
cwd: nodeModulesDir,
|
|
onlyDirectories: true,
|
|
deep: 1,
|
|
});
|
|
|
|
for (const packagePath of installedPackagePaths) {
|
|
try {
|
|
await this.runDirectoryLoader(
|
|
LazyPackageDirectoryLoader,
|
|
path.join(nodeModulesDir, packagePath),
|
|
);
|
|
} catch (error) {
|
|
this.logger.error((error as Error).message);
|
|
this.errorReporter.error(error);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolves the node icon file path when loaded from /icons/${packageName}/${iconPath}.
|
|
*
|
|
* Using N8N_CUSTOM_EXTENSIONS, nodes can be loaded from any directory outside of CWD='$N8N_USER_FOLDER/.n8n/'.
|
|
* Custom nodes are loaded by custom-directory-loader.ts using an absolute path, different from the default package-directory-loader.ts.
|
|
* The icon loading logic for custom nodes seems a bit broken, because icons are resolved by absolute paths encoded in URLs.
|
|
* Examples when served from `/icons/${packageName}/${iconPath}`:
|
|
* - '/icons/CUSTOM//home/node/.n8n-custom'
|
|
* - '/icons/CUSTOM/C:/User/name/.n8n-custom'
|
|
*
|
|
* resolveIcon() has a special path.resolve() strategy for custom nodes considering:
|
|
* - An absolute Linux file path is encoded in the URL using '//'.
|
|
* - '//' in URLs can be normalized to '/' by proxies, load balancers, etc.
|
|
* - A Windows file path starts with drive letters like 'C:' not '/'.
|
|
*
|
|
* @todo Instead of fixing the broken custom node loading strategy here, make custom-directory-loader.ts also use relative paths.
|
|
* Besides having different icon loading strategies, encoding an absolute path in URLs seems a security risk.
|
|
*/
|
|
resolveIcon(packageName: string, url: string): string | undefined {
|
|
const isCustom = packageName === CUSTOM_NODES_PACKAGE_NAME;
|
|
const loader = this.loaders[packageName];
|
|
if (!loader || !(loader instanceof DirectoryLoader)) {
|
|
return undefined;
|
|
}
|
|
|
|
const resolvePath = (iconPath: string) => {
|
|
return path.resolve(loader.directory, iconPath);
|
|
};
|
|
|
|
const resolvePathCustom = (path: string) => {
|
|
if (isWindowsFilePath(path)) return path;
|
|
return path.startsWith('/') ? path : '/' + path;
|
|
};
|
|
|
|
const pathPrefix = `/icons/${packageName}/`;
|
|
const urlFilePath = url.substring(pathPrefix.length);
|
|
if (isCustom && !isWindowsFilePath(urlFilePath)) {
|
|
const relativeFilePath = resolvePath(urlFilePath);
|
|
if (isContainedWithin(loader.directory, relativeFilePath)) {
|
|
return relativeFilePath;
|
|
}
|
|
}
|
|
|
|
const filePath = isCustom ? resolvePathCustom(urlFilePath) : resolvePath(urlFilePath);
|
|
return isContainedWithin(loader.directory, filePath) ? filePath : undefined;
|
|
}
|
|
|
|
resolveSchema({
|
|
node,
|
|
version,
|
|
resource,
|
|
operation,
|
|
}: {
|
|
node: string;
|
|
version: string;
|
|
resource?: string;
|
|
operation?: string;
|
|
}): string | undefined {
|
|
const nodePath = this.known.nodes[node]?.sourcePath;
|
|
if (!nodePath) {
|
|
return undefined;
|
|
}
|
|
|
|
return resolveOutputSchemaPath({
|
|
nodeDir: path.dirname(nodePath),
|
|
version,
|
|
resource,
|
|
operation,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Schema lookup for mock/pin-data generation: parsed `__schema__` content
|
|
* with version fallback (same major first, then older, then newer — see the
|
|
* n8n-core resolver), resolved through `known.nodes` so it works for
|
|
* community nodes and production installs alike.
|
|
*/
|
|
createOutputSchemaLookup(): OutputSchemaLookup {
|
|
return ({ type, typeVersion, resource, operation, hasOutputParser }) => {
|
|
const nodePath = this.known.nodes[type]?.sourcePath;
|
|
if (!nodePath) return undefined;
|
|
|
|
return loadOutputSchema({
|
|
nodeDir: path.dirname(nodePath),
|
|
version: typeVersion,
|
|
resource,
|
|
operation,
|
|
versionFallback: true,
|
|
variant: hasOutputParser ? OUTPUT_PARSER_SCHEMA_VARIANT : undefined,
|
|
});
|
|
};
|
|
}
|
|
|
|
getCustomDirectories(): string[] {
|
|
const customDirectories = [this.instanceSettings.customExtensionDir];
|
|
|
|
if (process.env[CUSTOM_EXTENSION_ENV] !== undefined) {
|
|
const customExtensionFolders = process.env[CUSTOM_EXTENSION_ENV].split(';');
|
|
customDirectories.push(...customExtensionFolders);
|
|
}
|
|
|
|
return customDirectories;
|
|
}
|
|
|
|
private async loadNodesFromCustomDirectories(): Promise<void> {
|
|
for (const directory of this.getCustomDirectories()) {
|
|
await this.runDirectoryLoader(CustomDirectoryLoader, directory);
|
|
}
|
|
}
|
|
|
|
async loadPackage(packageName: string) {
|
|
const finalNodeUnpackedPath = path.join(
|
|
this.instanceSettings.nodesDownloadDir,
|
|
'node_modules',
|
|
packageName,
|
|
);
|
|
return await this.runDirectoryLoader(PackageDirectoryLoader, finalNodeUnpackedPath);
|
|
}
|
|
|
|
async unloadPackage(packageName: string) {
|
|
if (packageName in this.loaders) {
|
|
this.loaders[packageName].reset();
|
|
delete this.loaders[packageName];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether any of the node's credential types may be used to
|
|
* make a request from a node other than itself.
|
|
*/
|
|
private supportsProxyAuth(description: INodeTypeDescription) {
|
|
if (!description.credentials) return false;
|
|
|
|
return description.credentials.some(({ name }) => {
|
|
const credType = this.types.credentials.find((t) => t.name === name);
|
|
if (!credType) {
|
|
this.logger.warn(
|
|
`Failed to load Custom API options for the node "${description.name}": Unknown credential name "${name}"`,
|
|
);
|
|
return false;
|
|
}
|
|
if (credType.authenticate !== undefined) return true;
|
|
|
|
return (
|
|
Array.isArray(credType.extends) &&
|
|
credType.extends.some((parentType) =>
|
|
['oAuth2Api', 'googleOAuth2Api', 'oAuth1Api'].includes(parentType),
|
|
)
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Inject a `Custom API Call` option into `resource` and `operation`
|
|
* parameters in a latest-version node that supports proxy auth.
|
|
*/
|
|
private injectCustomApiCallOptions() {
|
|
this.types.nodes.forEach((node: INodeTypeDescription) => {
|
|
const isLatestVersion =
|
|
node.defaultVersion === undefined || node.defaultVersion === node.version;
|
|
|
|
if (isLatestVersion) {
|
|
if (!this.supportsProxyAuth(node)) return;
|
|
|
|
node.properties.forEach((p) => {
|
|
if (
|
|
['resource', 'operation'].includes(p.name) &&
|
|
Array.isArray(p.options) &&
|
|
p.options[p.options.length - 1].name !== CUSTOM_API_CALL_NAME
|
|
) {
|
|
p.options.push({
|
|
name: CUSTOM_API_CALL_NAME,
|
|
value: CUSTOM_API_CALL_KEY,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
private shouldInjectContextEstablishmentHooks() {
|
|
return process.env.N8N_ENV_FEAT_DYNAMIC_CREDENTIALS === 'true';
|
|
}
|
|
|
|
private injectContextEstablishmentHooks() {
|
|
// Check if the feature is enabled via environment variable
|
|
const isEnabled = this.shouldInjectContextEstablishmentHooks();
|
|
|
|
if (!isEnabled) {
|
|
this.logger.debug('Context establishment hooks feature is disabled');
|
|
return;
|
|
}
|
|
|
|
const triggerNodes = this.types.nodes.filter((node: INodeTypeDescription) =>
|
|
node.group.includes('trigger'),
|
|
);
|
|
|
|
this.logger.debug(
|
|
`Injecting context establishment hooks for ${triggerNodes.length} trigger nodes`,
|
|
);
|
|
|
|
triggerNodes.forEach(this.augmentNodeTypeDescription);
|
|
}
|
|
|
|
private augmentNodeTypeDescription = (node: INodeTypeDescription) => {
|
|
const hooks = this.executionContextHookRegistry.getHookForTriggerType(node.name);
|
|
|
|
if (hooks.length > 0) {
|
|
this.logger.debug(`Found ${hooks.length} hooks for trigger node: ${node.name}`);
|
|
}
|
|
|
|
// Only inject hook properties if there are applicable hooks
|
|
if (hooks.length === 0) return;
|
|
|
|
// This prevents double-injection if the function is called multiple times on the same node
|
|
if (node.properties.some((p) => p.name === 'executionsHooksVersion')) return;
|
|
|
|
// Create a fixedCollection with multipleValues for multiple hook selection
|
|
// Each hook becomes a separate item that can be added multiple times
|
|
const allHookValues: INodeProperties[] = [
|
|
{
|
|
displayName: 'User Identifier',
|
|
name: 'hookName',
|
|
type: 'options',
|
|
noDataExpression: true,
|
|
options: hooks.map((hook) => {
|
|
const displayName = hook.hookDescription.displayName ?? hook.hookDescription.name;
|
|
return {
|
|
name: displayName,
|
|
value: hook.hookDescription.name,
|
|
description: `Use ${displayName} hook`,
|
|
};
|
|
}),
|
|
// No default - force user to explicitly select a hook
|
|
// This ensures hookName is always serialized in the workflow JSON
|
|
default: '',
|
|
description:
|
|
'Configure how n8n extracts the identity token of the user triggering a webhook. It is used to run each execution with the correct user.',
|
|
required: true,
|
|
},
|
|
];
|
|
|
|
// Add all hook-specific options with display conditions
|
|
for (const hook of hooks) {
|
|
const hookOptions = hook.hookDescription.options ?? [];
|
|
if (hookOptions.length > 0) {
|
|
for (const hookOption of hookOptions) {
|
|
// Add display condition to show only when this specific hook is selected
|
|
const enhancedOption: INodeProperties = {
|
|
...hookOption,
|
|
displayOptions: {
|
|
...hookOption.displayOptions,
|
|
show: {
|
|
...hookOption.displayOptions?.show,
|
|
hookName: [hook.hookDescription.name],
|
|
},
|
|
},
|
|
};
|
|
allHookValues.push(enhancedOption);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Create a hidden version property to track the hooks format version
|
|
const executionsHooksVersion: INodeProperties = {
|
|
displayName: 'Executions Hooks Version',
|
|
name: 'executionsHooksVersion',
|
|
type: 'hidden',
|
|
default: 1,
|
|
};
|
|
|
|
// Create the main context establishment hooks property as a fixedCollection
|
|
const contextHooksProperty: INodeProperties = {
|
|
displayName: 'Identify user for end-user credentials',
|
|
name: 'contextEstablishmentHooks',
|
|
type: 'fixedCollection',
|
|
placeholder: 'Add User Identifier',
|
|
default: {},
|
|
typeOptions: {
|
|
multipleValues: true,
|
|
hideEmptyMessage: true,
|
|
},
|
|
options: [
|
|
{
|
|
name: 'hooks',
|
|
displayName: 'Hooks',
|
|
values: allHookValues,
|
|
},
|
|
],
|
|
description:
|
|
'Configure how n8n extracts the identity token of the user triggering a webhook. It is used to run each execution with the correct user.',
|
|
};
|
|
|
|
// Create a notice that always appears after the hooks collection
|
|
const contextHooksNotice: INodeProperties = {
|
|
displayName:
|
|
'Configure how n8n extracts the identity token of the user triggering a webhook. It is used to run each execution with the correct user.',
|
|
name: 'contextHooksNotice',
|
|
type: 'notice',
|
|
default: '',
|
|
};
|
|
|
|
let index = node.properties.findIndex((p) => p.name === 'options');
|
|
if (index === -1) {
|
|
index = node.properties.length;
|
|
}
|
|
|
|
node.properties.splice(index, 0, contextHooksNotice);
|
|
node.properties.splice(index, 0, contextHooksProperty);
|
|
node.properties.splice(index, 0, executionsHooksVersion);
|
|
};
|
|
|
|
/**
|
|
* Run a loader of source files of nodes and credentials in a directory.
|
|
*/
|
|
private async runDirectoryLoader<T extends DirectoryLoader>(
|
|
constructor: Class<T, ConstructorParameters<typeof DirectoryLoader>>,
|
|
dir: string,
|
|
) {
|
|
const loader = new constructor(dir, this.excludeNodes, this.includeNodes);
|
|
if (loader instanceof PackageDirectoryLoader && loader.packageName in this.loaders) {
|
|
throw new UserError(
|
|
picocolors.red(
|
|
`nodes package ${loader.packageName} is already loaded.\n Please delete this second copy at path ${dir}`,
|
|
),
|
|
);
|
|
}
|
|
await loader.loadAll();
|
|
this.loaders[loader.packageName] = loader;
|
|
return loader;
|
|
}
|
|
|
|
async postProcessLoaders() {
|
|
this.known = { nodes: {}, credentials: {} };
|
|
this.loaded = { nodes: {}, credentials: {} };
|
|
this.types = { nodes: [], credentials: [] };
|
|
|
|
for (const loader of Object.values(this.loaders)) {
|
|
// Reload types if they were released from memory
|
|
await loader.ensureTypesLoaded();
|
|
|
|
// list of node & credential types that will be sent to the frontend
|
|
const { known, types, packageName } = loader;
|
|
this.types.nodes = this.types.nodes.concat(
|
|
types.nodes.map(({ name, ...rest }) => ({
|
|
...rest,
|
|
name: `${packageName}.${name}`,
|
|
})),
|
|
);
|
|
|
|
const processedCredentials = types.credentials.map((credential) => ({
|
|
...credential,
|
|
properties: injectDomainRestrictionFields(credential),
|
|
supportedNodes:
|
|
loader instanceof PackageDirectoryLoader
|
|
? credential.supportedNodes?.map((nodeName) => `${loader.packageName}.${nodeName}`)
|
|
: undefined,
|
|
}));
|
|
|
|
this.types.credentials = this.types.credentials.concat(processedCredentials);
|
|
|
|
// Add domain restriction fields to loaded credentials
|
|
for (const credentialTypeName in known.credentials) {
|
|
const credentialType = loader.getCredential(credentialTypeName);
|
|
credentialType.type.properties = injectDomainRestrictionFields(credentialType.type);
|
|
}
|
|
|
|
for (const type in known.nodes) {
|
|
const { className, sourcePath } = known.nodes[type];
|
|
this.known.nodes[`${packageName}.${type}`] = {
|
|
className,
|
|
sourcePath: loader.resolveSourcePath(sourcePath),
|
|
};
|
|
}
|
|
|
|
for (const type in known.credentials) {
|
|
const {
|
|
className,
|
|
sourcePath,
|
|
supportedNodes,
|
|
extends: extendsArr,
|
|
} = known.credentials[type];
|
|
this.known.credentials[type] = {
|
|
className,
|
|
sourcePath: loader.resolveSourcePath(sourcePath),
|
|
supportedNodes:
|
|
loader instanceof PackageDirectoryLoader
|
|
? supportedNodes?.map((nodeName) => `${loader.packageName}.${nodeName}`)
|
|
: undefined,
|
|
extends: extendsArr,
|
|
};
|
|
}
|
|
}
|
|
|
|
createAiTools(this.types, this.known);
|
|
createHitlTools(this.types, this.known);
|
|
|
|
this.injectCustomApiCallOptions();
|
|
|
|
this.injectContextEstablishmentHooks();
|
|
|
|
for (const postProcessor of this.postProcessors) {
|
|
await postProcessor();
|
|
}
|
|
}
|
|
|
|
recognizesNode(fullNodeType: string): boolean {
|
|
const [packageName, nodeType] = fullNodeType.split('.');
|
|
const { loaders } = this;
|
|
const loader = loaders[packageName];
|
|
return !!loader && nodeType in loader.known.nodes;
|
|
}
|
|
|
|
getNode(fullNodeType: string): LoadedClass<INodeType | IVersionedNodeType> {
|
|
const [packageName, nodeType] = fullNodeType.split('.');
|
|
const { loaders } = this;
|
|
const loader = loaders[packageName];
|
|
if (!loader) {
|
|
throw new UnrecognizedNodeTypeError(packageName, nodeType);
|
|
}
|
|
const loadedNode = loader.getNode(nodeType);
|
|
if (
|
|
this.shouldInjectContextEstablishmentHooks() &&
|
|
'properties' in loadedNode.type.description
|
|
) {
|
|
this.augmentNodeTypeDescription(loadedNode.type.description);
|
|
}
|
|
return loadedNode;
|
|
}
|
|
|
|
getCredential(credentialType: string): LoadedClass<ICredentialType> {
|
|
const { loadedCredentials } = this;
|
|
|
|
for (const loader of Object.values(this.loaders)) {
|
|
if (credentialType in loader.known.credentials) {
|
|
const loaded = loader.getCredential(credentialType);
|
|
loadedCredentials[credentialType] = loaded;
|
|
}
|
|
}
|
|
|
|
if (credentialType in loadedCredentials) {
|
|
return loadedCredentials[credentialType];
|
|
}
|
|
|
|
throw new UnrecognizedCredentialTypeError(credentialType);
|
|
}
|
|
|
|
async setupHotReload() {
|
|
const { default: debounce } = await import('lodash/debounce.js');
|
|
|
|
const { subscribe } = await import('@parcel/watcher');
|
|
|
|
const { Push } = await import('@/push/index.js');
|
|
const push = Container.get(Push);
|
|
|
|
for (const loader of Object.values(this.loaders)) {
|
|
if (!(loader instanceof DirectoryLoader)) continue;
|
|
const { directory } = loader;
|
|
try {
|
|
await fsPromises.access(directory);
|
|
} catch {
|
|
// If directory doesn't exist, there is nothing to watch
|
|
continue;
|
|
}
|
|
|
|
const reloader = debounce(async () => {
|
|
this.logger.info(`Hot reload triggered for ${loader.packageName}`);
|
|
try {
|
|
loader.reset();
|
|
await loader.loadAll();
|
|
await this.postProcessLoaders();
|
|
push.broadcast({ type: 'nodeDescriptionUpdated', data: {} });
|
|
} catch (error) {
|
|
this.logger.error(`Hot reload failed for ${loader.packageName}`);
|
|
}
|
|
}, 100);
|
|
|
|
// For lazy loaded packages, we need to watch the dist directory
|
|
const watchPaths = loader.isLazyLoaded ? [path.join(directory, 'dist')] : [directory];
|
|
const customNodesRoot = path.join(directory, 'node_modules');
|
|
|
|
if (loader.packageName === CUSTOM_NODES_PACKAGE_NAME) {
|
|
const customNodeEntries = await fsPromises.readdir(customNodesRoot, {
|
|
withFileTypes: true,
|
|
});
|
|
|
|
// Custom nodes are usually symlinked using npm link. Resolve symlinks to support file watching
|
|
const realCustomNodesPaths = await Promise.all(
|
|
customNodeEntries
|
|
.filter(
|
|
(entry) =>
|
|
(entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith('.'),
|
|
)
|
|
.map(
|
|
async (entry) =>
|
|
await fsPromises.realpath(path.join(customNodesRoot, entry.name)).catch(() => null),
|
|
),
|
|
);
|
|
|
|
watchPaths.push.apply(
|
|
watchPaths,
|
|
realCustomNodesPaths.filter((path): path is string => !!path),
|
|
);
|
|
}
|
|
|
|
this.logger.debug('Watching node folders for hot reload', {
|
|
loader: loader.packageName,
|
|
paths: watchPaths,
|
|
});
|
|
|
|
for (const watchPath of watchPaths) {
|
|
const onFileEvent: ParcelWatcher.SubscribeCallback = async (_error, events) => {
|
|
if (events.some((event) => event.type !== 'delete')) {
|
|
const modules = Object.keys(require.cache).filter((module) =>
|
|
module.startsWith(watchPath),
|
|
);
|
|
|
|
for (const module of modules) {
|
|
delete require.cache[module];
|
|
}
|
|
await reloader();
|
|
}
|
|
};
|
|
|
|
// Ignore nested node_modules folders
|
|
const ignore = ['**/node_modules/**/node_modules/**'];
|
|
|
|
await subscribe(watchPath, onFileEvent, { ignore });
|
|
}
|
|
}
|
|
}
|
|
}
|