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:
Mutasem Aldmour
2026-02-10 15:47:01 +01:00
committed by GitHub
parent 2bba36b662
commit cd175ddda0
14 changed files with 61 additions and 586 deletions
@@ -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[];
/**
@@ -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[];
/**
@@ -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`);
@@ -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');
}
}
}