mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
refactor(ai-builder): Remove community node support from code builder (#25575)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -175,7 +175,7 @@ export function createLangsmithClient(logger?: EvalLogger): LangsmithClientResul
|
||||
|
||||
/**
|
||||
* Resolve built-in node definition directories from installed node packages.
|
||||
* Mirrors `NodeDefinitionGeneratorService.getBuiltinDefinitionDirs()` for use
|
||||
* Mirrors `WorkflowBuilderService.resolveBuiltinNodeDefinitionDirs()` for use
|
||||
* in the eval harness where the DI container is not available.
|
||||
*/
|
||||
export function resolveBuiltinNodeDefinitionDirs(): string[] {
|
||||
|
||||
@@ -179,8 +179,13 @@ export class AiWorkflowBuilderService {
|
||||
'@n8n/n8n-nodes-langchain.code',
|
||||
]);
|
||||
|
||||
const isBuiltInNode = (name: string) =>
|
||||
name.startsWith('n8n-nodes-base.') || name.startsWith('@n8n/');
|
||||
|
||||
const visibleNodeTypes = nodeTypes.filter(
|
||||
(nodeType) =>
|
||||
// Only include built-in nodes (community nodes are not supported)
|
||||
isBuiltInNode(nodeType.name) &&
|
||||
// We filter out hidden nodes, except for the Data Table node which has custom hiding logic
|
||||
// See more details in DataTable.node.ts#L29
|
||||
!ignoredTypes.has(nodeType.name) &&
|
||||
|
||||
@@ -37,9 +37,7 @@ export interface CodeWorkflowBuilderConfig {
|
||||
/** Optional logger */
|
||||
logger?: Logger;
|
||||
/**
|
||||
* Ordered list of directories to search for node definitions.
|
||||
* Built-in dirs come first, then the community dir.
|
||||
* If not provided, falls back to ~/.n8n/node-definitions.
|
||||
* Ordered list of directories to search for built-in node definitions.
|
||||
*/
|
||||
nodeDefinitionDirs?: string[];
|
||||
/**
|
||||
|
||||
+3
-9
@@ -7,7 +7,6 @@
|
||||
|
||||
import { tool } from '@langchain/core/tools';
|
||||
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -52,17 +51,14 @@ export function validatePathWithinBase(filePath: string, baseDir: string): boole
|
||||
|
||||
/**
|
||||
* Get the paths to the generated nodes directories.
|
||||
* Searches multiple directories in order (built-in first, then community).
|
||||
* Falls back to ~/.n8n/node-definitions if no dirs are provided.
|
||||
* Searches the configured built-in definition directories.
|
||||
*/
|
||||
function getGeneratedNodesPaths(nodeDefinitionDirs?: string[]): string[] {
|
||||
if (nodeDefinitionDirs && nodeDefinitionDirs.length > 0) {
|
||||
return nodeDefinitionDirs.map((dir) => join(dir, 'nodes'));
|
||||
}
|
||||
|
||||
// Default to ~/.n8n/node-definitions (same location as runtime and CLI)
|
||||
const defaultTypesDir = join(homedir(), '.n8n', 'node-definitions');
|
||||
return [join(defaultTypesDir, 'nodes')];
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -572,9 +568,7 @@ type NodeRequest =
|
||||
*/
|
||||
export interface CodeBuilderGetToolOptions {
|
||||
/**
|
||||
* Ordered list of directories to search for node definitions.
|
||||
* Built-in dirs come first, then the community dir.
|
||||
* If not provided, falls back to ~/.n8n/node-definitions.
|
||||
* Ordered list of directories to search for built-in node definitions.
|
||||
*/
|
||||
nodeDefinitionDirs?: string[];
|
||||
}
|
||||
|
||||
@@ -100,9 +100,7 @@ export interface CodeBuilderAgentConfig {
|
||||
/** Optional logger */
|
||||
logger?: Logger;
|
||||
/**
|
||||
* Ordered list of directories to search for node definitions.
|
||||
* Built-in dirs come first, then the community dir.
|
||||
* If not provided, falls back to ~/.n8n/node-definitions.
|
||||
* Ordered list of directories to search for built-in node definitions.
|
||||
*/
|
||||
nodeDefinitionDirs?: string[];
|
||||
/**
|
||||
|
||||
+24
-6
@@ -77,7 +77,7 @@ describe('AiWorkflowBuilderService', () => {
|
||||
|
||||
const mockNodeTypeDescriptions: INodeTypeDescription[] = [
|
||||
{
|
||||
name: 'TestNode',
|
||||
name: 'n8n-nodes-base.testNode',
|
||||
displayName: 'Test Node',
|
||||
description: 'A test node',
|
||||
version: 1,
|
||||
@@ -95,7 +95,7 @@ describe('AiWorkflowBuilderService', () => {
|
||||
group: ['transform'],
|
||||
},
|
||||
{
|
||||
name: 'HiddenNode',
|
||||
name: 'n8n-nodes-base.hiddenNode',
|
||||
displayName: 'Hidden Node',
|
||||
description: 'A hidden node',
|
||||
version: 1,
|
||||
@@ -130,7 +130,7 @@ describe('AiWorkflowBuilderService', () => {
|
||||
group: ['transform'],
|
||||
},
|
||||
{
|
||||
name: 'TestNodeTool',
|
||||
name: 'n8n-nodes-base.testNodeTool',
|
||||
displayName: 'Test Tool Node',
|
||||
description: 'Test tool node description',
|
||||
version: 1,
|
||||
@@ -147,6 +147,17 @@ describe('AiWorkflowBuilderService', () => {
|
||||
],
|
||||
group: ['transform'],
|
||||
},
|
||||
{
|
||||
name: 'community-nodes-test.someNode',
|
||||
displayName: 'Community Node',
|
||||
description: 'A community node that should be filtered out',
|
||||
version: 1,
|
||||
defaults: {},
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [],
|
||||
group: ['transform'],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -234,7 +245,7 @@ describe('AiWorkflowBuilderService', () => {
|
||||
|
||||
expect(testService).toBeInstanceOf(AiWorkflowBuilderService);
|
||||
expect(MockedSessionManagerService).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'TestNode' })]),
|
||||
expect.arrayContaining([expect.objectContaining({ name: 'n8n-nodes-base.testNode' })]),
|
||||
mockLogger,
|
||||
);
|
||||
});
|
||||
@@ -262,10 +273,15 @@ describe('AiWorkflowBuilderService', () => {
|
||||
expect(MockedSessionManagerService).toHaveBeenCalledTimes(1);
|
||||
const filteredNodeTypes = MockedSessionManagerService.mock.calls[0][0];
|
||||
|
||||
expect(filteredNodeTypes.find((node) => node.name === 'HiddenNode')).toBeUndefined();
|
||||
expect(
|
||||
filteredNodeTypes.find((node) => node.name === 'n8n-nodes-base.hiddenNode'),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
filteredNodeTypes.find((node) => node.name === '@n8n/n8n-nodes-langchain.toolVectorStore'),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
filteredNodeTypes.find((node) => node.name === 'community-nodes-test.someNode'),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
filteredNodeTypes.find((node) => node.name === 'n8n-nodes-base.dataTable'),
|
||||
).toMatchObject({ name: 'n8n-nodes-base.dataTable' });
|
||||
@@ -287,7 +303,9 @@ describe('AiWorkflowBuilderService', () => {
|
||||
expect(MockedSessionManagerService).toHaveBeenCalledTimes(1);
|
||||
const filteredNodeTypes = MockedSessionManagerService.mock.calls[0][0];
|
||||
|
||||
const testToolNode = filteredNodeTypes.find((node) => node.name === 'TestNodeTool');
|
||||
const testToolNode = filteredNodeTypes.find(
|
||||
(node) => node.name === 'n8n-nodes-base.testNodeTool',
|
||||
);
|
||||
expect(testToolNode).toBeDefined();
|
||||
expect(testToolNode?.description).toBe('Test tool node description');
|
||||
expect(testToolNode?.displayName).toBe('Test Tool Node');
|
||||
|
||||
@@ -71,9 +71,7 @@ export interface WorkflowBuilderAgentConfig {
|
||||
/** Callback when generation completes successfully (not aborted) */
|
||||
onGenerationSuccess?: () => Promise<void>;
|
||||
/**
|
||||
* Ordered list of directories to search for node definitions.
|
||||
* Built-in dirs come first, then the community dir.
|
||||
* If not provided, falls back to ~/.n8n/node-definitions.
|
||||
* Ordered list of directories to search for built-in node definitions.
|
||||
*/
|
||||
nodeDefinitionDirs?: string[];
|
||||
/** Callback for fetching resource locator options */
|
||||
|
||||
@@ -13,14 +13,13 @@
|
||||
* pnpm generate-types
|
||||
*
|
||||
* Output:
|
||||
* ~/.n8n/node-definitions/
|
||||
* dist/node-definitions/ (within each node package)
|
||||
*
|
||||
* @generated - This file generates code, but is itself manually maintained.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import { deepCopy } from 'n8n-workflow';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// eslint-disable-next-line import-x/no-cycle -- TODO: Refactor shared types/utils to break cycle
|
||||
@@ -42,7 +41,8 @@ const NODES_LANGCHAIN_TYPES = path.resolve(
|
||||
__dirname,
|
||||
'../../../nodes-langchain/dist/types/nodes.json',
|
||||
);
|
||||
const OUTPUT_PATH = path.join(os.homedir(), '.n8n', 'node-definitions');
|
||||
/** Dev script output path (local to the package) */
|
||||
const DEV_OUTPUT_PATH = path.resolve(__dirname, '../../dist/node-definitions');
|
||||
|
||||
// Path to nodes-base dist for finding output schemas
|
||||
const NODES_BASE_DIST = path.resolve(__dirname, '../../../../nodes-base/dist/nodes');
|
||||
@@ -3984,7 +3984,7 @@ export async function generateTypes(): Promise<void> {
|
||||
);
|
||||
}
|
||||
|
||||
const result = await orchestrateGeneration({ nodes: allNodes, outputDir: OUTPUT_PATH });
|
||||
const result = await orchestrateGeneration({ nodes: allNodes, outputDir: DEV_OUTPUT_PATH });
|
||||
|
||||
if (result.nodeCount === 0) {
|
||||
// Generate placeholder if no nodes found
|
||||
@@ -4001,7 +4001,7 @@ export async function generateTypes(): Promise<void> {
|
||||
|
||||
export {};
|
||||
`;
|
||||
await fs.promises.writeFile(path.join(OUTPUT_PATH, 'index.ts'), placeholderContent);
|
||||
await fs.promises.writeFile(path.join(DEV_OUTPUT_PATH, 'index.ts'), placeholderContent);
|
||||
console.log('Generated placeholder index.ts (no nodes found)');
|
||||
} else {
|
||||
console.log(`Generated definitions for ${result.nodeCount} nodes`);
|
||||
|
||||
-67
@@ -15,7 +15,6 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||
import type { License } from '@/license';
|
||||
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import type { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import type { NodeDefinitionGeneratorService } from '@/services/node-definition-generator.service';
|
||||
import { COMMUNITY_NODE_VERSION, COMMUNITY_PACKAGE_VERSION } from '@test-integration/constants';
|
||||
import { mockPackageName, mockPackagePair } from '@test-integration/utils';
|
||||
|
||||
@@ -65,7 +64,6 @@ describe('CommunityPackagesService', () => {
|
||||
|
||||
const logger = mock<Logger>();
|
||||
const publisher = mock<Publisher>();
|
||||
const nodeDefinitionGenerator = mock<NodeDefinitionGeneratorService>();
|
||||
|
||||
const communityPackagesService = new CommunityPackagesService(
|
||||
instanceSettings,
|
||||
@@ -75,7 +73,6 @@ describe('CommunityPackagesService', () => {
|
||||
publisher,
|
||||
license,
|
||||
config,
|
||||
nodeDefinitionGenerator,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -891,68 +888,4 @@ describe('CommunityPackagesService', () => {
|
||||
expect(callOrder).toEqual(['unloadPackage', 'loadPackage']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('node definition generation hooks', () => {
|
||||
const PACKAGE_NAME = 'n8n-nodes-test-hooks';
|
||||
|
||||
const packageDirectoryLoader = mock<PackageDirectoryLoader>({
|
||||
loadedNodes: [{ name: 'a-node', version: 1 }],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
jest.clearAllMocks();
|
||||
|
||||
config.unverifiedEnabled = true;
|
||||
license.isCustomNpmRegistryEnabled.mockReturnValue(true);
|
||||
|
||||
mocked(executeNpmCommand).mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'pack') return `${PACKAGE_NAME}-latest.tgz`;
|
||||
return 'Done';
|
||||
});
|
||||
mocked(execFile).mockImplementation(((...args: unknown[]) => {
|
||||
const cb = args[args.length - 1] as ExecFileCallback;
|
||||
cb(null, 'Done', '');
|
||||
}) as typeof execFile);
|
||||
mocked(readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: PACKAGE_NAME,
|
||||
version: '1.0.0',
|
||||
dependencies: {},
|
||||
}),
|
||||
);
|
||||
mocked(writeFile).mockResolvedValue(undefined);
|
||||
mocked(rm).mockResolvedValue(undefined);
|
||||
mocked(mkdir).mockResolvedValue(undefined as never);
|
||||
|
||||
loadNodesAndCredentials.loadPackage.mockResolvedValue(packageDirectoryLoader);
|
||||
loadNodesAndCredentials.unloadPackage.mockResolvedValue(undefined);
|
||||
loadNodesAndCredentials.postProcessLoaders.mockResolvedValue(undefined);
|
||||
|
||||
installedPackageRepository.saveInstalledPackageWithNodes.mockResolvedValue(
|
||||
mock<InstalledPackages>({ packageName: PACKAGE_NAME }),
|
||||
);
|
||||
installedPackageRepository.remove.mockResolvedValue(undefined as never);
|
||||
|
||||
publisher.publishCommand.mockResolvedValue(undefined);
|
||||
|
||||
nodeDefinitionGenerator.generateForPackage.mockResolvedValue(undefined);
|
||||
nodeDefinitionGenerator.removeForPackage.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test('should generate node definitions after installing a community package', async () => {
|
||||
await communityPackagesService.installPackage(PACKAGE_NAME, '1.0.0');
|
||||
|
||||
expect(nodeDefinitionGenerator.generateForPackage).toHaveBeenCalledTimes(1);
|
||||
expect(nodeDefinitionGenerator.generateForPackage.mock.calls[0][0]).toBe(PACKAGE_NAME);
|
||||
});
|
||||
|
||||
test('should remove node definitions after removing a community package', async () => {
|
||||
const installedPkg = mock<InstalledPackages>({ packageName: PACKAGE_NAME });
|
||||
|
||||
await communityPackagesService.removePackage(PACKAGE_NAME, installedPkg);
|
||||
|
||||
expect(nodeDefinitionGenerator.removeForPackage).toHaveBeenCalledWith(PACKAGE_NAME);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,6 @@ import { FeatureNotLicensedError } from '@/errors/feature-not-licensed.error';
|
||||
import { License } from '@/license';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import { Publisher } from '@/scaling/pubsub/publisher.service';
|
||||
import { NodeDefinitionGeneratorService } from '@/services/node-definition-generator.service';
|
||||
import { toError } from '@/utils';
|
||||
|
||||
import { getCommunityNodeTypes, type StrapiCommunityNodeType } from './community-node-types-utils';
|
||||
@@ -70,7 +69,6 @@ export class CommunityPackagesService {
|
||||
private readonly publisher: Publisher,
|
||||
private readonly license: License,
|
||||
private readonly config: CommunityPackagesConfig,
|
||||
private readonly nodeDefinitionGenerator: NodeDefinitionGeneratorService,
|
||||
) {}
|
||||
|
||||
async init() {
|
||||
@@ -433,7 +431,6 @@ export class CommunityPackagesService {
|
||||
});
|
||||
await this.loadNodesAndCredentials.postProcessLoaders();
|
||||
this.loadNodesAndCredentials.releaseTypes();
|
||||
await this.nodeDefinitionGenerator.generateForPackage(packageName, loader.types.nodes);
|
||||
this.logger.info(`Community package installed: ${packageName}`);
|
||||
return installedPackage;
|
||||
} catch (error) {
|
||||
@@ -479,7 +476,6 @@ export class CommunityPackagesService {
|
||||
private async removeNpmPackage(packageName: string) {
|
||||
await this.deletePackageDirectory(packageName);
|
||||
await this.loadNodesAndCredentials.unloadPackage(packageName);
|
||||
await this.nodeDefinitionGenerator.removeForPackage(packageName);
|
||||
await this.loadNodesAndCredentials.postProcessLoaders();
|
||||
this.loadNodesAndCredentials.releaseTypes();
|
||||
this.logger.info(`Community package uninstalled: ${packageName}`);
|
||||
|
||||
@@ -10,7 +10,6 @@ import type { License } from '@/license';
|
||||
import type { Push } from '@/push';
|
||||
import { WorkflowBuilderService } from '@/services/ai-workflow-builder.service';
|
||||
import type { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
|
||||
import type { NodeDefinitionGeneratorService } from '@/services/node-definition-generator.service';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
import type { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import type { Telemetry } from '@/telemetry';
|
||||
@@ -34,7 +33,6 @@ describe('WorkflowBuilderService', () => {
|
||||
let mockPush: Push;
|
||||
let mockTelemetry: Telemetry;
|
||||
let mockInstanceSettings: InstanceSettings;
|
||||
let mockNodeDefinitionGenerator: NodeDefinitionGeneratorService;
|
||||
let mockDynamicNodeParametersService: DynamicNodeParametersService;
|
||||
let mockUser: IUser;
|
||||
|
||||
@@ -72,7 +70,6 @@ describe('WorkflowBuilderService', () => {
|
||||
mockPush = mock<Push>();
|
||||
mockTelemetry = mock<Telemetry>();
|
||||
mockInstanceSettings = mock<InstanceSettings>();
|
||||
mockNodeDefinitionGenerator = mock<NodeDefinitionGeneratorService>();
|
||||
mockDynamicNodeParametersService = mock<DynamicNodeParametersService>();
|
||||
mockUser = mock<IUser>();
|
||||
mockUser.id = 'test-user-id';
|
||||
@@ -82,7 +79,6 @@ describe('WorkflowBuilderService', () => {
|
||||
(mockLicense.loadCertStr as jest.Mock).mockResolvedValue('test-cert');
|
||||
(mockLicense.getConsumerId as jest.Mock).mockReturnValue('test-consumer-id');
|
||||
(mockInstanceSettings.instanceId as unknown) = 'test-instance-id';
|
||||
(mockNodeDefinitionGenerator.getNodeDefinitionDirs as jest.Mock).mockReturnValue([]);
|
||||
mockConfig.aiAssistant = { baseUrl: '' };
|
||||
|
||||
// Reset the mocked AiWorkflowBuilderService
|
||||
@@ -98,7 +94,6 @@ describe('WorkflowBuilderService', () => {
|
||||
mockPush,
|
||||
mockTelemetry,
|
||||
mockInstanceSettings,
|
||||
mockNodeDefinitionGenerator,
|
||||
mockDynamicNodeParametersService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* Tests for NodeDefinitionGeneratorService
|
||||
*
|
||||
* Following TDD: These tests are written BEFORE the implementation.
|
||||
* Run with: cd packages/cli && pnpm jest src/services/__tests__/node-definition-generator.service.test.ts
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { Logger } from '@n8n/backend-common';
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import { NodeDefinitionGeneratorService } from '../node-definition-generator.service';
|
||||
|
||||
// Mock fs module
|
||||
jest.mock('fs', () => ({
|
||||
promises: {
|
||||
readFile: jest.fn(),
|
||||
writeFile: jest.fn(),
|
||||
mkdir: jest.fn(),
|
||||
rm: jest.fn(),
|
||||
},
|
||||
existsSync: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('NodeDefinitionGeneratorService', () => {
|
||||
const mockLogger = mock<Logger>();
|
||||
const nodeDefinitionsDir = '/test/.n8n/node-definitions';
|
||||
const mockInstanceSettings = mock<InstanceSettings>({ nodeDefinitionsDir });
|
||||
|
||||
let service: NodeDefinitionGeneratorService;
|
||||
|
||||
const sampleNodesJson = JSON.stringify([
|
||||
{
|
||||
name: 'n8n-nodes-base.httpRequest',
|
||||
displayName: 'HTTP Request',
|
||||
version: [4, 4.1, 4.2],
|
||||
properties: [],
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
group: ['transform'],
|
||||
},
|
||||
]);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
service = new NodeDefinitionGeneratorService(mockLogger, mockInstanceSettings);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should be injectable with Logger and InstanceSettings', () => {
|
||||
expect(service).toBeDefined();
|
||||
expect(service).toBeInstanceOf(NodeDefinitionGeneratorService);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateIfNeeded', () => {
|
||||
const nodesJsonPath = '/path/to/nodes.json';
|
||||
const hashFilePath = path.join(nodeDefinitionsDir, 'nodes.json.hash');
|
||||
|
||||
it('should generate types if hash file does not exist', async () => {
|
||||
// Hash file doesn't exist
|
||||
(fs.existsSync as jest.Mock).mockReturnValue(false);
|
||||
(fs.promises.readFile as jest.Mock).mockResolvedValue(sampleNodesJson);
|
||||
(fs.promises.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.promises.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.generateIfNeeded(nodesJsonPath);
|
||||
|
||||
expect(result).toBe(true); // Types were generated
|
||||
expect(fs.promises.mkdir).toHaveBeenCalledWith(
|
||||
expect.stringContaining('node-definitions'),
|
||||
expect.objectContaining({ recursive: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should skip generation if hash matches', async () => {
|
||||
const content = sampleNodesJson;
|
||||
const expectedHash = service.computeHash(content);
|
||||
|
||||
// Hash file exists with matching hash
|
||||
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(fs.promises.readFile as jest.Mock).mockImplementation(async (filePath: string) => {
|
||||
if (filePath === nodesJsonPath) return content;
|
||||
if (filePath === hashFilePath) return expectedHash;
|
||||
throw new Error('Unexpected file');
|
||||
});
|
||||
|
||||
const result = await service.generateIfNeeded(nodesJsonPath);
|
||||
|
||||
expect(result).toBe(false); // Types were NOT generated (hash matched)
|
||||
});
|
||||
|
||||
it('should regenerate types if hash differs', async () => {
|
||||
const content = sampleNodesJson;
|
||||
|
||||
// Hash file exists but with different hash
|
||||
(fs.existsSync as jest.Mock).mockReturnValue(true);
|
||||
(fs.promises.readFile as jest.Mock).mockImplementation(async (filePath: string) => {
|
||||
if (filePath === nodesJsonPath) return content;
|
||||
if (filePath === hashFilePath) return 'old-different-hash';
|
||||
throw new Error('Unexpected file');
|
||||
});
|
||||
(fs.promises.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.promises.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await service.generateIfNeeded(nodesJsonPath);
|
||||
|
||||
expect(result).toBe(true); // Types were regenerated
|
||||
// New hash should be written
|
||||
expect(fs.promises.writeFile).toHaveBeenCalledWith(hashFilePath, expect.any(String), 'utf-8');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generate', () => {
|
||||
const nodesJsonPath = '/path/to/nodes.json';
|
||||
|
||||
it('should create the node-definitions directory if it does not exist', async () => {
|
||||
(fs.promises.readFile as jest.Mock).mockResolvedValue(sampleNodesJson);
|
||||
(fs.promises.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.promises.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await service.generate(nodesJsonPath);
|
||||
|
||||
expect(fs.promises.mkdir).toHaveBeenCalledWith(nodeDefinitionsDir, { recursive: true });
|
||||
});
|
||||
|
||||
it('should write the hash file after generation', async () => {
|
||||
const hashFilePath = path.join(nodeDefinitionsDir, 'nodes.json.hash');
|
||||
|
||||
(fs.promises.readFile as jest.Mock).mockResolvedValue(sampleNodesJson);
|
||||
(fs.promises.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.promises.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await service.generate(nodesJsonPath);
|
||||
|
||||
// Should write hash file
|
||||
expect(fs.promises.writeFile).toHaveBeenCalledWith(hashFilePath, expect.any(String), 'utf-8');
|
||||
});
|
||||
|
||||
it('should generate node type files in the nodes subdirectory', async () => {
|
||||
(fs.promises.readFile as jest.Mock).mockResolvedValue(sampleNodesJson);
|
||||
(fs.promises.mkdir as jest.Mock).mockResolvedValue(undefined);
|
||||
(fs.promises.writeFile as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await service.generate(nodesJsonPath);
|
||||
|
||||
// Should create node directories
|
||||
expect(fs.promises.mkdir).toHaveBeenCalledWith(
|
||||
expect.stringContaining('nodes'),
|
||||
expect.objectContaining({ recursive: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('computeHash', () => {
|
||||
it('should compute MD5 hash of content', () => {
|
||||
const content = 'test content';
|
||||
const hash = service.computeHash(content);
|
||||
|
||||
expect(hash).toBeDefined();
|
||||
expect(typeof hash).toBe('string');
|
||||
expect(hash.length).toBe(32); // MD5 hash is 32 hex chars
|
||||
});
|
||||
|
||||
it('should return different hashes for different content', () => {
|
||||
const hash1 = service.computeHash('content1');
|
||||
const hash2 = service.computeHash('content2');
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
|
||||
it('should return same hash for same content', () => {
|
||||
const content = 'same content';
|
||||
const hash1 = service.computeHash(content);
|
||||
const hash2 = service.computeHash(content);
|
||||
|
||||
expect(hash1).toBe(hash2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNodeDefinitionDirs', () => {
|
||||
it('should return built-in dirs from node packages first, then community dir', () => {
|
||||
const dirs = service.getNodeDefinitionDirs();
|
||||
// Should have at least the community dir
|
||||
expect(dirs).toContain(nodeDefinitionsDir);
|
||||
// Community dir should be last
|
||||
expect(dirs[dirs.length - 1]).toBe(nodeDefinitionsDir);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeForPackage', () => {
|
||||
it('should remove package directory from nodeDefinitionsDir', async () => {
|
||||
(fs.promises.rm as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await service.removeForPackage('n8n-nodes-custom');
|
||||
|
||||
expect(fs.promises.rm).toHaveBeenCalledWith(
|
||||
path.join(nodeDefinitionsDir, 'nodes', 'n8n-nodes-custom'),
|
||||
{ recursive: true, force: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,9 @@ import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import { AiAssistantClient } from '@n8n_io/ai-assistant-sdk';
|
||||
import * as fs from 'fs';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import * as path from 'path';
|
||||
import type {
|
||||
INodeCredentials,
|
||||
INodeParameters,
|
||||
@@ -19,7 +21,6 @@ import { License } from '@/license';
|
||||
import { LoadNodesAndCredentials } from '@/load-nodes-and-credentials';
|
||||
import { Push } from '@/push';
|
||||
import { DynamicNodeParametersService } from '@/services/dynamic-node-parameters.service';
|
||||
import { NodeDefinitionGeneratorService } from '@/services/node-definition-generator.service';
|
||||
import { UrlService } from '@/services/url.service';
|
||||
import { Telemetry } from '@/telemetry';
|
||||
import { getBase } from '@/workflow-execute-additional-data';
|
||||
@@ -45,7 +46,6 @@ export class WorkflowBuilderService {
|
||||
private readonly push: Push,
|
||||
private readonly telemetry: Telemetry,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly nodeDefinitionGenerator: NodeDefinitionGeneratorService,
|
||||
private readonly dynamicNodeParametersService: DynamicNodeParametersService,
|
||||
) {
|
||||
// Register a post-processor to update node types when they change.
|
||||
@@ -156,13 +156,30 @@ export class WorkflowBuilderService {
|
||||
N8N_VERSION,
|
||||
onCreditsUpdated,
|
||||
onTelemetryEvent,
|
||||
this.nodeDefinitionGenerator.getNodeDefinitionDirs(),
|
||||
this.resolveBuiltinNodeDefinitionDirs(),
|
||||
resourceLocatorCallbackFactory,
|
||||
);
|
||||
|
||||
return this.service;
|
||||
}
|
||||
|
||||
private resolveBuiltinNodeDefinitionDirs(): string[] {
|
||||
const dirs: string[] = [];
|
||||
for (const packageId of ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']) {
|
||||
try {
|
||||
const packageJsonPath = require.resolve(`${packageId}/package.json`);
|
||||
const distDir = path.dirname(packageJsonPath);
|
||||
const nodeDefsDir = path.join(distDir, 'dist', 'node-definitions');
|
||||
if (fs.existsSync(nodeDefsDir)) {
|
||||
dirs.push(nodeDefsDir);
|
||||
}
|
||||
} catch {
|
||||
// Package not installed, skip
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
async *chat(payload: ChatPayload, user: IUser, abortSignal?: AbortSignal) {
|
||||
const service = await this.getService();
|
||||
yield* service.chat(payload, user, abortSignal);
|
||||
|
||||
@@ -1,272 +0,0 @@
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { Service } from '@n8n/di';
|
||||
import { createHash } from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { jsonParse, type INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
import {
|
||||
generateSingleVersionTypeFile,
|
||||
generateSingleVersionSchemaFile,
|
||||
generateVersionIndexFile,
|
||||
generateIndexFile,
|
||||
hasDiscriminatorPattern,
|
||||
planSplitVersionFiles,
|
||||
versionToFileName,
|
||||
nodeNameToFileName,
|
||||
getPackageName,
|
||||
orchestrateGeneration,
|
||||
type NodeTypeDescription,
|
||||
} from '@n8n/workflow-sdk';
|
||||
|
||||
/**
|
||||
* Service for generating node definitions at runtime.
|
||||
*
|
||||
* Generates TypeScript type definitions and Zod schemas from nodes.json files
|
||||
* and stores them in the ~/.n8n/node-definitions/ directory.
|
||||
*
|
||||
* For built-in nodes, definitions are pre-generated at build time.
|
||||
* This service handles community/custom nodes at install time.
|
||||
*/
|
||||
@Service()
|
||||
export class NodeDefinitionGeneratorService {
|
||||
private generationInProgress: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly logger: Logger,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Compute MD5 hash of content
|
||||
*/
|
||||
computeHash(content: string): string {
|
||||
return createHash('md5').update(content).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if types need to be regenerated and generate if needed.
|
||||
* Uses a lock to prevent concurrent generation runs.
|
||||
*
|
||||
* @param nodesJsonPath Path to the nodes.json file
|
||||
* @returns true if types were generated, false if skipped (hash matched or already in progress)
|
||||
*/
|
||||
async generateIfNeeded(nodesJsonPath: string): Promise<boolean> {
|
||||
// If generation is already in progress, wait for it and return false
|
||||
if (this.generationInProgress) {
|
||||
this.logger.debug('Node type generation already in progress, waiting...');
|
||||
await this.generationInProgress;
|
||||
return false;
|
||||
}
|
||||
|
||||
const hashFilePath = path.join(this.instanceSettings.nodeDefinitionsDir, 'nodes.json.hash');
|
||||
|
||||
// Read the nodes.json content
|
||||
const content = await fs.promises.readFile(nodesJsonPath, 'utf-8');
|
||||
const currentHash = this.computeHash(content);
|
||||
|
||||
// Check if hash file exists and matches
|
||||
if (fs.existsSync(hashFilePath)) {
|
||||
try {
|
||||
const storedHash = await fs.promises.readFile(hashFilePath, 'utf-8');
|
||||
if (storedHash.trim() === currentHash) {
|
||||
this.logger.debug('Node types up to date, skipping generation');
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// If we can't read the hash file, regenerate
|
||||
}
|
||||
}
|
||||
|
||||
// Generate types with lock
|
||||
this.generationInProgress = this.generate(nodesJsonPath);
|
||||
try {
|
||||
await this.generationInProgress;
|
||||
return true;
|
||||
} finally {
|
||||
this.generationInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate type files from nodes.json
|
||||
*
|
||||
* @param nodesJsonPath Path to the nodes.json file
|
||||
*/
|
||||
async generate(nodesJsonPath: string): Promise<void> {
|
||||
const outputDir = this.instanceSettings.nodeDefinitionsDir;
|
||||
const hashFilePath = path.join(outputDir, 'nodes.json.hash');
|
||||
|
||||
this.logger.info('Generating node types and schemas from nodes.json...');
|
||||
|
||||
// Ensure output directory exists
|
||||
await fs.promises.mkdir(outputDir, { recursive: true });
|
||||
|
||||
// Read and parse nodes.json
|
||||
const content = await fs.promises.readFile(nodesJsonPath, 'utf-8');
|
||||
const nodes = jsonParse<NodeTypeDescription[]>(content);
|
||||
|
||||
// Group nodes by package
|
||||
const nodesByPackage = new Map<string, Map<string, NodeTypeDescription[]>>();
|
||||
|
||||
for (const node of nodes) {
|
||||
if (node.hidden) continue;
|
||||
|
||||
const packageName = getPackageName(node.name);
|
||||
const fileName = nodeNameToFileName(node.name);
|
||||
|
||||
if (!nodesByPackage.has(packageName)) {
|
||||
nodesByPackage.set(packageName, new Map());
|
||||
}
|
||||
|
||||
const packageNodes = nodesByPackage.get(packageName)!;
|
||||
if (!packageNodes.has(fileName)) {
|
||||
packageNodes.set(fileName, []);
|
||||
}
|
||||
packageNodes.get(fileName)!.push(node);
|
||||
}
|
||||
|
||||
const allNodes: NodeTypeDescription[] = [];
|
||||
|
||||
// Generate files for each package
|
||||
for (const [packageName, nodesByName] of nodesByPackage) {
|
||||
const packageDir = path.join(outputDir, 'nodes', packageName);
|
||||
|
||||
for (const [nodeName, nodeVariants] of nodesByName) {
|
||||
try {
|
||||
// Create directory for this node
|
||||
const nodeDir = path.join(packageDir, nodeName);
|
||||
await fs.promises.mkdir(nodeDir, { recursive: true });
|
||||
|
||||
// Collect all versions
|
||||
const versionToNode = new Map<number, NodeTypeDescription>();
|
||||
const allVersions: number[] = [];
|
||||
|
||||
for (const node of nodeVariants) {
|
||||
const versions = Array.isArray(node.version) ? node.version : [node.version];
|
||||
for (const version of versions) {
|
||||
if (!versionToNode.has(version)) {
|
||||
versionToNode.set(version, node);
|
||||
allVersions.push(version);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate files for each version
|
||||
for (const version of allVersions) {
|
||||
const sourceNode = versionToNode.get(version)!;
|
||||
const fileName = versionToFileName(version);
|
||||
|
||||
if (hasDiscriminatorPattern(sourceNode)) {
|
||||
// Generate split structure for nodes with resource/operation or mode patterns
|
||||
// planSplitVersionFiles returns both type files AND schema files
|
||||
const versionDir = path.join(nodeDir, fileName);
|
||||
await fs.promises.mkdir(versionDir, { recursive: true });
|
||||
const files = planSplitVersionFiles(sourceNode, version);
|
||||
await this.writePlanToDisk(versionDir, files);
|
||||
} else {
|
||||
// Generate flat type file
|
||||
const typeContent = generateSingleVersionTypeFile(sourceNode, version);
|
||||
const filePath = path.join(nodeDir, `${fileName}.ts`);
|
||||
await fs.promises.writeFile(filePath, typeContent, 'utf-8');
|
||||
|
||||
// Generate flat schema file
|
||||
const schemaContent = generateSingleVersionSchemaFile(sourceNode, version);
|
||||
const schemaFilePath = path.join(nodeDir, `${fileName}.schema.js`);
|
||||
await fs.promises.writeFile(schemaFilePath, schemaContent, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate index.ts
|
||||
const indexContent = generateVersionIndexFile(nodeVariants[0], allVersions);
|
||||
await fs.promises.writeFile(path.join(nodeDir, 'index.ts'), indexContent, 'utf-8');
|
||||
|
||||
// Add first node variant for main index
|
||||
allNodes.push(nodeVariants[0]);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error generating types for ${nodeName}:`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate main index file
|
||||
if (allNodes.length > 0) {
|
||||
const indexContent = generateIndexFile(allNodes);
|
||||
await fs.promises.writeFile(path.join(outputDir, 'index.ts'), indexContent, 'utf-8');
|
||||
}
|
||||
|
||||
// Write hash file
|
||||
const hash = this.computeHash(content);
|
||||
await fs.promises.writeFile(hashFilePath, hash, 'utf-8');
|
||||
|
||||
this.logger.info(`Generated types and schemas for ${allNodes.length} nodes`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns ordered list of directories to search for node definitions.
|
||||
* Built-in dirs (from node packages) come first, then the community dir.
|
||||
*/
|
||||
getNodeDefinitionDirs(): string[] {
|
||||
return [...this.getBuiltinDefinitionDirs(), this.instanceSettings.nodeDefinitionsDir];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate node definitions for a community package into nodeDefinitionsDir.
|
||||
*/
|
||||
async generateForPackage(_packageName: string, nodeTypes: INodeTypeDescription[]): Promise<void> {
|
||||
await orchestrateGeneration({
|
||||
nodes: nodeTypes as unknown as NodeTypeDescription[],
|
||||
outputDir: this.instanceSettings.nodeDefinitionsDir,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove generated definitions for a community package from nodeDefinitionsDir.
|
||||
*/
|
||||
async removeForPackage(packageName: string): Promise<void> {
|
||||
const packageDir = path.join(this.instanceSettings.nodeDefinitionsDir, 'nodes', packageName);
|
||||
await fs.promises.rm(packageDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve built-in node definition directories from installed node packages.
|
||||
*/
|
||||
private getBuiltinDefinitionDirs(): string[] {
|
||||
const dirs: string[] = [];
|
||||
for (const packageId of ['n8n-nodes-base', '@n8n/n8n-nodes-langchain']) {
|
||||
try {
|
||||
const packageJsonPath = require.resolve(`${packageId}/package.json`);
|
||||
const distDir = path.dirname(packageJsonPath);
|
||||
const nodeDefsDir = path.join(distDir, 'dist', 'node-definitions');
|
||||
if (fs.existsSync(nodeDefsDir)) {
|
||||
dirs.push(nodeDefsDir);
|
||||
}
|
||||
} catch {
|
||||
// Package not installed, skip
|
||||
}
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a plan (Map of relative paths to content) to disk
|
||||
*
|
||||
* @param baseDir Base directory for the files
|
||||
* @param plan Map of relative path -> file content
|
||||
*/
|
||||
private async writePlanToDisk(baseDir: string, plan: Map<string, string>): Promise<void> {
|
||||
const resolvedBase = path.resolve(baseDir);
|
||||
for (const [relativePath, content] of plan) {
|
||||
const fullPath = path.resolve(baseDir, relativePath);
|
||||
if (!fullPath.startsWith(resolvedBase + path.sep) && fullPath !== resolvedBase) {
|
||||
throw new Error(`Path traversal detected: ${relativePath}`);
|
||||
}
|
||||
const dir = path.dirname(fullPath);
|
||||
await fs.promises.mkdir(dir, { recursive: true });
|
||||
await fs.promises.writeFile(fullPath, content, 'utf-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user