Integrations
n8n
Custom Nodes

Custom Node Development

Learn how to develop custom n8n nodes for Canvelete and extend the integration with advanced functionality.

Overview

While the official Canvelete n8n community node covers most use cases, you may need to create custom nodes for:

  • Specialized Operations: Custom business logic or data transformations
  • Advanced Authentication: Custom OAuth2 flows or API key management
  • Extended Functionality: Features not available in the community node
  • Enterprise Integration: Custom endpoints or proprietary workflows

This guide covers the complete process of developing, testing, and deploying custom n8n nodes for Canvelete.

Prerequisites

Before developing custom nodes, ensure you have:

  • Node.js: Version 18 or higher
  • TypeScript: Familiarity with TypeScript development
  • n8n Knowledge: Understanding of n8n's architecture and node system
  • Canvelete API: Knowledge of Canvelete's REST API endpoints
  • Development Environment: Local n8n installation for testing

Development Environment Setup

1. Initialize Node Project

Create a new directory for your custom node:

# Create project directory
mkdir n8n-nodes-canvelete-custom
cd n8n-nodes-canvelete-custom
 
# Initialize npm project
npm init -y
 
# Install n8n development dependencies
npm install --save-dev n8n-workflow n8n-core @types/node typescript
npm install --save-dev @typescript-eslint/eslint-plugin @typescript-eslint/parser
npm install --save-dev eslint prettier
 
# Install runtime dependencies
npm install axios form-data

2. Configure TypeScript

Create tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2019",
    "module": "commonjs",
    "lib": ["ES2019"],
    "declaration": true,
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "moduleResolution": "node",
    "allowSyntheticDefaultImports": true,
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

3. Project Structure

Create the following directory structure:

n8n-nodes-canvelete-custom/
├── src/
│   ├── credentials/
│   │   └── CanveleteCustomApi.credentials.ts
│   ├── nodes/
│   │   └── CanveleteCustom/
│   │       ├── CanveleteCustom.node.ts
│   │       ├── GenericFunctions.ts
│   │       ├── descriptions/
│   │       │   ├── DesignDescription.ts
│   │       │   ├── RenderDescription.ts
│   │       │   └── TemplateDescription.ts
│   │       └── canvelete-custom.svg
│   └── types/
│       └── index.ts
├── package.json
├── tsconfig.json
└── README.md

Creating Custom Credentials

OAuth2 Credential Implementation

Create src/credentials/CanveleteCustomApi.credentials.ts:

import {
  IAuthenticateGeneric,
  ICredentialTestRequest,
  ICredentialType,
  INodeProperties,
} from 'n8n-workflow';
 
export class CanveleteCustomApi implements ICredentialType {
  name = 'canveleteCustomApi';
  displayName = 'Canvelete Custom API';
  documentationUrl = 'https://docs.canvelete.com/integrations/n8n/custom-nodes';
  properties: INodeProperties[] = [
    {
      displayName: 'Grant Type',
      name: 'grantType',
      type: 'hidden',
      default: 'authorizationCode',
    },
    {
      displayName: 'Authorization URL',
      name: 'authUrl',
      type: 'hidden',
      default: 'https://canvelete.com/api/oauth/authorize',
    },
    {
      displayName: 'Access Token URL',
      name: 'accessTokenUrl',
      type: 'hidden',
      default: 'https://canvelete.com/api/oauth/token',
    },
    {
      displayName: 'Client ID',
      name: 'clientId',
      type: 'string',
      default: 'n8n-custom',
      required: true,
    },
    {
      displayName: 'Client Secret',
      name: 'clientSecret',
      type: 'string',
      typeOptions: {
        password: true,
      },
      default: '',
      required: true,
    },
    {
      displayName: 'Scope',
      name: 'scope',
      type: 'hidden',
      default: 'openid profile email designs:read designs:write templates:read render:write apikeys:read apikeys:write',
    },
    {
      displayName: 'Auth URI Query Parameters',
      name: 'authQueryParameters',
      type: 'hidden',
      default: '',
    },
    {
      displayName: 'Authentication',
      name: 'authentication',
      type: 'hidden',
      default: 'body',
    },
    {
      displayName: 'Environment',
      name: 'environment',
      type: 'options',
      options: [
        {
          name: 'Production',
          value: 'production',
        },
        {
          name: 'Beta',
          value: 'beta',
        },
        {
          name: 'Development',
          value: 'development',
        },
      ],
      default: 'beta',
      description: 'Canvelete environment to connect to',
    },
    {
      displayName: 'Custom Base URL',
      name: 'customBaseUrl',
      type: 'string',
      displayOptions: {
        show: {
          environment: ['development'],
        },
      },
      default: 'http://localhost:3000',
      placeholder: 'http://localhost:3000',
      description: 'Custom base URL for development environment',
    },
  ];
 
  authenticate: IAuthenticateGeneric = {
    type: 'generic',
    properties: {
      headers: {
        Authorization: '=Bearer {{$credentials.oauthTokenData.access_token}}',
      },
    },
  };
 
  test: ICredentialTestRequest = {
    request: {
      baseURL: '={{$credentials.environment === "production" ? "https://canvelete.com" : $credentials.environment === "beta" ? "https://canvelete.com" : $credentials.customBaseUrl}}',
      url: '/api/automation/designs',
      method: 'GET',
      qs: {
        limit: 1,
      },
    },
  };
}

API Key Credential (Alternative)

Create src/credentials/CanveleteApiKey.credentials.ts:

import {
  IAuthenticateGeneric,
  ICredentialTestRequest,
  ICredentialType,
  INodeProperties,
} from 'n8n-workflow';
 
export class CanveleteApiKey implements ICredentialType {
  name = 'canveleteApiKey';
  displayName = 'Canvelete API Key';
  documentationUrl = 'https://docs.canvelete.com/integrations/n8n/custom-nodes';
  properties: INodeProperties[] = [
    {
      displayName: 'API Key',
      name: 'apiKey',
      type: 'string',
      typeOptions: {
        password: true,
      },
      default: '',
      required: true,
      description: 'Your Canvelete API key',
    },
    {
      displayName: 'Environment',
      name: 'environment',
      type: 'options',
      options: [
        {
          name: 'Production',
          value: 'production',
        },
        {
          name: 'Beta',
          value: 'beta',
        },
        {
          name: 'Development',
          value: 'development',
        },
      ],
      default: 'beta',
    },
    {
      displayName: 'Custom Base URL',
      name: 'customBaseUrl',
      type: 'string',
      displayOptions: {
        show: {
          environment: ['development'],
        },
      },
      default: 'http://localhost:3000',
    },
  ];
 
  authenticate: IAuthenticateGeneric = {
    type: 'generic',
    properties: {
      headers: {
        'X-API-Key': '={{$credentials.apiKey}}',
      },
    },
  };
 
  test: ICredentialTestRequest = {
    request: {
      baseURL: '={{$credentials.environment === "production" ? "https://canvelete.com" : $credentials.environment === "beta" ? "https://canvelete.com" : $credentials.customBaseUrl}}',
      url: '/api/automation/designs',
      method: 'GET',
    },
  };
}

Building Custom Node Logic

Generic Functions

Create src/nodes/CanveleteCustom/GenericFunctions.ts:

import {
  IExecuteFunctions,
  IHookFunctions,
  ILoadOptionsFunctions,
  IWebhookFunctions,
  IHttpRequestOptions,
  IDataObject,
  NodeApiError,
  NodeOperationError,
} from 'n8n-workflow';
 
export async function canveleteApiRequest(
  this: IHookFunctions | IExecuteFunctions | ILoadOptionsFunctions | IWebhookFunctions,
  method: string,
  resource: string,
  body: any = {},
  qs: IDataObject = {},
  uri?: string,
  headers: IDataObject = {},
): Promise<any> {
  const credentials = await this.getCredentials('canveleteCustomApi');
  
  const baseUrl = getBaseUrl(credentials);
  
  const options: IHttpRequestOptions = {
    method,
    headers: {
      'Content-Type': 'application/json',
      ...headers,
    },
    body,
    qs,
    uri: uri || `${baseUrl}/api/automation${resource}`,
    json: true,
  };
 
  try {
    if (Object.keys(body).length === 0) {
      delete options.body;
    }
 
    return await this.helpers.httpRequestWithAuthentication.call(
      this,
      'canveleteCustomApi',
      options,
    );
  } catch (error) {
    throw new NodeApiError(this.getNode(), error);
  }
}
 
export async function canveleteApiRequestAllItems(
  this: IExecuteFunctions | ILoadOptionsFunctions,
  propertyName: string,
  method: string,
  endpoint: string,
  body: any = {},
  query: IDataObject = {},
): Promise<any> {
  const returnData: IDataObject[] = [];
 
  let responseData;
  query.limit = 100;
  query.offset = 0;
 
  do {
    responseData = await canveleteApiRequest.call(this, method, endpoint, body, query);
    
    if (responseData[propertyName]) {
      returnData.push.apply(returnData, responseData[propertyName]);
    }
    
    query.offset = (query.offset as number) + (query.limit as number);
  } while (responseData[propertyName] && responseData[propertyName].length !== 0);
 
  return returnData;
}
 
export function getBaseUrl(credentials: IDataObject): string {
  const environment = credentials.environment as string;
  
  switch (environment) {
    case 'production':
      return 'https://canvelete.com';
    case 'beta':
      return 'https://canvelete.com';
    case 'development':
      return credentials.customBaseUrl as string;
    default:
      return 'https://canvelete.com';
  }
}
 
export function validateDynamicData(data: string): object {
  try {
    return JSON.parse(data);
  } catch (error) {
    throw new NodeOperationError(
      { type: 'n8n-nodes-canvelete-custom', typeVersion: 1 } as any,
      `Invalid JSON in dynamic data: ${error.message}`,
    );
  }
}
 
export function formatRenderOptions(options: IDataObject): IDataObject {
  const formatted: IDataObject = {};
  
  if (options.format) {
    formatted.format = options.format;
  }
  
  if (options.width) {
    formatted.width = parseInt(options.width as string, 10);
  }
  
  if (options.height) {
    formatted.height = parseInt(options.height as string, 10);
  }
  
  if (options.quality) {
    formatted.quality = parseInt(options.quality as string, 10);
  }
  
  if (options.dynamicData) {
    formatted.dynamicData = validateDynamicData(options.dynamicData as string);
  }
  
  return formatted;
}

Main Node Implementation

Create src/nodes/CanveleteCustom/CanveleteCustom.node.ts:

import {
  IExecuteFunctions,
  INodeExecutionData,
  INodeType,
  INodeTypeDescription,
  NodeOperationError,
} from 'n8n-workflow';
 
import {
  canveleteApiRequest,
  canveleteApiRequestAllItems,
  formatRenderOptions,
} from './GenericFunctions';
 
import { designOperations, designFields } from './descriptions/DesignDescription';
import { renderOperations, renderFields } from './descriptions/RenderDescription';
import { templateOperations, templateFields } from './descriptions/TemplateDescription';
 
export class CanveleteCustom implements INodeType {
  description: INodeTypeDescription = {
    displayName: 'Canvelete Custom',
    name: 'canveleteCustom',
    icon: 'file:canvelete-custom.svg',
    group: ['output'],
    version: 1,
    subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}',
    description: 'Custom Canvelete node with advanced functionality',
    defaults: {
      name: 'Canvelete Custom',
    },
    inputs: ['main'],
    outputs: ['main'],
    credentials: [
      {
        name: 'canveleteCustomApi',
        required: true,
      },
    ],
    properties: [
      {
        displayName: 'Resource',
        name: 'resource',
        type: 'options',
        noDataExpression: true,
        options: [
          {
            name: 'Design',
            value: 'design',
          },
          {
            name: 'Render',
            value: 'render',
          },
          {
            name: 'Template',
            value: 'template',
          },
          {
            name: 'Batch',
            value: 'batch',
          },
          {
            name: 'Analytics',
            value: 'analytics',
          },
        ],
        default: 'design',
      },
      ...designOperations,
      ...renderOperations,
      ...templateOperations,
      // Batch operations
      {
        displayName: 'Operation',
        name: 'operation',
        type: 'options',
        noDataExpression: true,
        displayOptions: {
          show: {
            resource: ['batch'],
          },
        },
        options: [
          {
            name: 'Render Multiple',
            value: 'renderMultiple',
            description: 'Render multiple templates with different data',
            action: 'Render multiple templates',
          },
          {
            name: 'Create Designs Batch',
            value: 'createDesignsBatch',
            description: 'Create multiple designs in batch',
            action: 'Create multiple designs',
          },
        ],
        default: 'renderMultiple',
      },
      // Analytics operations
      {
        displayName: 'Operation',
        name: 'operation',
        type: 'options',
        noDataExpression: true,
        displayOptions: {
          show: {
            resource: ['analytics'],
          },
        },
        options: [
          {
            name: 'Get Usage Stats',
            value: 'getUsageStats',
            description: 'Get API usage statistics',
            action: 'Get usage statistics',
          },
          {
            name: 'Get Render History',
            value: 'getRenderHistory',
            description: 'Get detailed render history with analytics',
            action: 'Get render history',
          },
        ],
        default: 'getUsageStats',
      },
      ...designFields,
      ...renderFields,
      ...templateFields,
      // Batch fields
      {
        displayName: 'Batch Data',
        name: 'batchData',
        type: 'json',
        displayOptions: {
          show: {
            resource: ['batch'],
            operation: ['renderMultiple', 'createDesignsBatch'],
          },
        },
        default: '[]',
        description: 'Array of objects containing batch operation data',
        placeholder: '[{"templateId": "123", "dynamicData": {"title": "Item 1"}}, {"templateId": "456", "dynamicData": {"title": "Item 2"}}]',
      },
      // Analytics fields
      {
        displayName: 'Date Range',
        name: 'dateRange',
        type: 'options',
        displayOptions: {
          show: {
            resource: ['analytics'],
          },
        },
        options: [
          {
            name: 'Last 7 Days',
            value: '7d',
          },
          {
            name: 'Last 30 Days',
            value: '30d',
          },
          {
            name: 'Last 90 Days',
            value: '90d',
          },
          {
            name: 'Custom Range',
            value: 'custom',
          },
        ],
        default: '7d',
      },
      {
        displayName: 'Start Date',
        name: 'startDate',
        type: 'dateTime',
        displayOptions: {
          show: {
            resource: ['analytics'],
            dateRange: ['custom'],
          },
        },
        default: '',
        description: 'Start date for custom range',
      },
      {
        displayName: 'End Date',
        name: 'endDate',
        type: 'dateTime',
        displayOptions: {
          show: {
            resource: ['analytics'],
            dateRange: ['custom'],
          },
        },
        default: '',
        description: 'End date for custom range',
      },
    ],
  };
 
  async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
    const items = this.getInputData();
    const returnData: INodeExecutionData[] = [];
    const length = items.length;
 
    const resource = this.getNodeParameter('resource', 0) as string;
    const operation = this.getNodeParameter('operation', 0) as string;
 
    for (let i = 0; i < length; i++) {
      try {
        let responseData;
 
        if (resource === 'design') {
          responseData = await this.executeDesignOperation(i, operation);
        } else if (resource === 'render') {
          responseData = await this.executeRenderOperation(i, operation);
        } else if (resource === 'template') {
          responseData = await this.executeTemplateOperation(i, operation);
        } else if (resource === 'batch') {
          responseData = await this.executeBatchOperation(i, operation);
        } else if (resource === 'analytics') {
          responseData = await this.executeAnalyticsOperation(i, operation);
        } else {
          throw new NodeOperationError(
            this.getNode(),
            `The resource "${resource}" is not known!`,
            { itemIndex: i },
          );
        }
 
        if (Array.isArray(responseData)) {
          returnData.push.apply(returnData, responseData);
        } else {
          returnData.push({
            json: responseData,
            pairedItem: { item: i },
          });
        }
      } catch (error) {
        if (this.continueOnFail()) {
          returnData.push({
            json: { error: error.message },
            pairedItem: { item: i },
          });
          continue;
        }
        throw error;
      }
    }
 
    return [returnData];
  }
 
  private async executeDesignOperation(itemIndex: number, operation: string): Promise<any> {
    // Implementation for design operations
    // Similar to community node but with custom enhancements
    
    if (operation === 'create') {
      const name = this.getNodeParameter('name', itemIndex) as string;
      const description = this.getNodeParameter('description', itemIndex, '') as string;
      const width = this.getNodeParameter('width', itemIndex, 1920) as number;
      const height = this.getNodeParameter('height', itemIndex, 1080) as number;
      const canvasData = this.getNodeParameter('canvasData', itemIndex, '{}') as string;
      
      const body = {
        name,
        description,
        width,
        height,
        canvasData: JSON.parse(canvasData),
      };
      
      return await canveleteApiRequest.call(this, 'POST', '/designs', body);
    }
    
    // Add other design operations...
    throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not supported for resource "design"!`);
  }
 
  private async executeRenderOperation(itemIndex: number, operation: string): Promise<any> {
    // Implementation for render operations with custom enhancements
    
    if (operation === 'create') {
      const templateId = this.getNodeParameter('templateId', itemIndex, '') as string;
      const designId = this.getNodeParameter('designId', itemIndex, '') as string;
      
      if (!templateId && !designId) {
        throw new NodeOperationError(this.getNode(), 'Either Template ID or Design ID must be provided!');
      }
      
      const options = formatRenderOptions({
        format: this.getNodeParameter('format', itemIndex, 'png'),
        width: this.getNodeParameter('width', itemIndex, ''),
        height: this.getNodeParameter('height', itemIndex, ''),
        quality: this.getNodeParameter('quality', itemIndex, ''),
        dynamicData: this.getNodeParameter('dynamicData', itemIndex, ''),
      });
      
      const body: any = { ...options };
      
      if (templateId) {
        body.templateId = templateId;
      } else {
        body.designId = designId;
      }
      
      return await canveleteApiRequest.call(this, 'POST', '/render', body);
    }
    
    throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not supported for resource "render"!`);
  }
 
  private async executeTemplateOperation(itemIndex: number, operation: string): Promise<any> {
    // Implementation for template operations
    
    if (operation === 'getMany') {
      const limit = this.getNodeParameter('limit', itemIndex, 50) as number;
      const returnAll = this.getNodeParameter('returnAll', itemIndex, false) as boolean;
      
      if (returnAll) {
        return await canveleteApiRequestAllItems.call(this, 'templates', 'GET', '/templates');
      } else {
        const qs = { limit };
        return await canveleteApiRequest.call(this, 'GET', '/templates', {}, qs);
      }
    }
    
    throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not supported for resource "template"!`);
  }
 
  private async executeBatchOperation(itemIndex: number, operation: string): Promise<any> {
    // Custom batch operations
    
    if (operation === 'renderMultiple') {
      const batchData = JSON.parse(this.getNodeParameter('batchData', itemIndex, '[]') as string);
      const results = [];
      
      for (const item of batchData) {
        try {
          const result = await canveleteApiRequest.call(this, 'POST', '/render', item);
          results.push({ success: true, data: result, input: item });
        } catch (error) {
          results.push({ success: false, error: error.message, input: item });
        }
      }
      
      return results.map(result => ({ json: result }));
    }
    
    throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not supported for resource "batch"!`);
  }
 
  private async executeAnalyticsOperation(itemIndex: number, operation: string): Promise<any> {
    // Custom analytics operations
    
    if (operation === 'getUsageStats') {
      const dateRange = this.getNodeParameter('dateRange', itemIndex) as string;
      let qs: any = {};
      
      if (dateRange === 'custom') {
        qs.startDate = this.getNodeParameter('startDate', itemIndex) as string;
        qs.endDate = this.getNodeParameter('endDate', itemIndex) as string;
      } else {
        qs.range = dateRange;
      }
      
      return await canveleteApiRequest.call(this, 'GET', '/analytics/usage', {}, qs);
    }
    
    throw new NodeOperationError(this.getNode(), `The operation "${operation}" is not supported for resource "analytics"!`);
  }
}

Advanced Features

Webhook Support

Create webhook functionality for real-time events:

// In your node class
webhookMethods = {
  default: {
    async checkExists(this: IHookFunctions): Promise<boolean> {
      const webhookUrl = this.getNodeWebhookUrl('default');
      const webhookData = this.getWorkflowStaticData('node');
      
      if (webhookData.webhookId === undefined) {
        return false;
      }
      
      const endpoint = `/webhooks/${webhookData.webhookId}`;
      
      try {
        await canveleteApiRequest.call(this, 'GET', endpoint);
        return true;
      } catch (error) {
        return false;
      }
    },
    
    async create(this: IHookFunctions): Promise<boolean> {
      const webhookUrl = this.getNodeWebhookUrl('default');
      const webhookData = this.getWorkflowStaticData('node');
      const events = this.getNodeParameter('events') as string[];
      
      const body = {
        url: webhookUrl,
        events,
        active: true,
      };
      
      const responseData = await canveleteApiRequest.call(this, 'POST', '/webhooks', body);
      
      if (responseData.id === undefined) {
        return false;
      }
      
      webhookData.webhookId = responseData.id as string;
      return true;
    },
    
    async delete(this: IHookFunctions): Promise<boolean> {
      const webhookData = this.getWorkflowStaticData('node');
      
      if (webhookData.webhookId !== undefined) {
        const endpoint = `/webhooks/${webhookData.webhookId}`;
        
        try {
          await canveleteApiRequest.call(this, 'DELETE', endpoint);
        } catch (error) {
          return false;
        }
        
        delete webhookData.webhookId;
      }
      
      return true;
    },
  },
};
 
async webhook(this: IWebhookFunctions): Promise<IWebhookResponseData> {
  const bodyData = this.getBodyData();
  
  return {
    workflowData: [
      this.helpers.returnJsonArray(bodyData),
    ],
  };
}

Load Options for Dynamic Dropdowns

Add dynamic loading for templates and designs:

methods = {
  loadOptions: {
    async getTemplates(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
      const returnData: INodePropertyOptions[] = [];
      
      try {
        const templates = await canveleteApiRequest.call(this, 'GET', '/templates', {}, { limit: 100 });
        
        for (const template of templates.templates) {
          returnData.push({
            name: template.name,
            value: template.id,
            description: template.description,
          });
        }
        
        return returnData;
      } catch (error) {
        throw new NodeOperationError(this.getNode(), `Failed to load templates: ${error.message}`);
      }
    },
    
    async getDesigns(this: ILoadOptionsFunctions): Promise<INodePropertyOptions[]> {
      const returnData: INodePropertyOptions[] = [];
      
      try {
        const designs = await canveleteApiRequest.call(this, 'GET', '/designs', {}, { limit: 100 });
        
        for (const design of designs.designs) {
          returnData.push({
            name: design.name,
            value: design.id,
            description: design.description || 'No description',
          });
        }
        
        return returnData;
      } catch (error) {
        throw new NodeOperationError(this.getNode(), `Failed to load designs: ${error.message}`);
      }
    },
  },
};

Testing and Debugging

Unit Testing

Create src/test/CanveleteCustom.node.test.ts:

import { IExecuteFunctions } from 'n8n-workflow';
import { CanveleteCustom } from '../nodes/CanveleteCustom/CanveleteCustom.node';
 
describe('CanveleteCustom', () => {
  let node: CanveleteCustom;
  let mockExecuteFunctions: Partial<IExecuteFunctions>;
 
  beforeEach(() => {
    node = new CanveleteCustom();
    mockExecuteFunctions = {
      getInputData: jest.fn().mockReturnValue([{ json: {} }]),
      getNodeParameter: jest.fn(),
      getCredentials: jest.fn().mockResolvedValue({
        environment: 'beta',
        clientId: 'test-client',
        clientSecret: 'test-secret',
      }),
      helpers: {
        httpRequestWithAuthentication: jest.fn(),
      },
    };
  });
 
  describe('Design Operations', () => {
    it('should create a design successfully', async () => {
      mockExecuteFunctions.getNodeParameter = jest.fn()
        .mockReturnValueOnce('design') // resource
        .mockReturnValueOnce('create') // operation
        .mockReturnValueOnce('Test Design') // name
        .mockReturnValueOnce('Test Description') // description
        .mockReturnValueOnce(1920) // width
        .mockReturnValueOnce(1080); // height
 
      mockExecuteFunctions.helpers!.httpRequestWithAuthentication = jest.fn()
        .mockResolvedValue({
          id: 'design_123',
          name: 'Test Design',
          description: 'Test Description',
        });
 
      const result = await node.execute.call(mockExecuteFunctions as IExecuteFunctions);
 
      expect(result[0]).toHaveLength(1);
      expect(result[0][0].json).toEqual({
        id: 'design_123',
        name: 'Test Design',
        description: 'Test Description',
      });
    });
  });
 
  describe('Batch Operations', () => {
    it('should handle batch rendering', async () => {
      const batchData = [
        { templateId: 'template_1', dynamicData: { title: 'Item 1' } },
        { templateId: 'template_2', dynamicData: { title: 'Item 2' } },
      ];
 
      mockExecuteFunctions.getNodeParameter = jest.fn()
        .mockReturnValueOnce('batch') // resource
        .mockReturnValueOnce('renderMultiple') // operation
        .mockReturnValueOnce(JSON.stringify(batchData)); // batchData
 
      mockExecuteFunctions.helpers!.httpRequestWithAuthentication = jest.fn()
        .mockResolvedValueOnce({ id: 'render_1', url: 'https://example.com/1.png' })
        .mockResolvedValueOnce({ id: 'render_2', url: 'https://example.com/2.png' });
 
      const result = await node.execute.call(mockExecuteFunctions as IExecuteFunctions);
 
      expect(result[0]).toHaveLength(2);
      expect(result[0][0].json.success).toBe(true);
      expect(result[0][1].json.success).toBe(true);
    });
  });
});

Integration Testing

Create src/test/integration.test.ts:

import { CanveleteCustom } from '../nodes/CanveleteCustom/CanveleteCustom.node';
 
describe('Integration Tests', () => {
  let node: CanveleteCustom;
 
  beforeEach(() => {
    node = new CanveleteCustom();
  });
 
  it('should connect to Canvelete API', async () => {
    // Test with real credentials in CI/CD environment
    if (process.env.CANVELETE_TEST_API_KEY) {
      // Run integration tests
    } else {
      console.log('Skipping integration tests - no API key provided');
    }
  });
});

Building and Packaging

Build Configuration

Update package.json:

{
  "name": "n8n-nodes-canvelete-custom",
  "version": "1.0.0",
  "description": "Custom n8n node for Canvelete with advanced features",
  "keywords": ["n8n-community-node-package"],
  "license": "MIT",
  "homepage": "https://docs.canvelete.com/integrations/n8n/custom-nodes",
  "author": {
    "name": "Your Name",
    "email": "your.email@example.com"
  },
  "repository": {
    "type": "git",
    "url": "https://github.com/your-org/n8n-nodes-canvelete-custom.git"
  },
  "main": "dist/index.js",
  "scripts": {
    "build": "tsc && npm run copy-assets",
    "copy-assets": "cp src/nodes/CanveleteCustom/*.svg dist/nodes/CanveleteCustom/",
    "dev": "tsc --watch",
    "format": "prettier --write .",
    "lint": "eslint . --ext .ts",
    "test": "jest",
    "test:watch": "jest --watch",
    "prepublishOnly": "npm run build && npm run lint && npm run test"
  },
  "files": [
    "dist"
  ],
  "n8n": {
    "n8nNodesApiVersion": 1,
    "credentials": [
      "dist/credentials/CanveleteCustomApi.credentials.js",
      "dist/credentials/CanveleteApiKey.credentials.js"
    ],
    "nodes": [
      "dist/nodes/CanveleteCustom/CanveleteCustom.node.js"
    ]
  },
  "devDependencies": {
    "@types/jest": "^29.5.0",
    "@types/node": "^18.15.0",
    "@typescript-eslint/eslint-plugin": "^5.57.0",
    "@typescript-eslint/parser": "^5.57.0",
    "eslint": "^8.37.0",
    "jest": "^29.5.0",
    "n8n-core": "^0.220.0",
    "n8n-workflow": "^0.220.0",
    "prettier": "^2.8.7",
    "ts-jest": "^29.1.0",
    "typescript": "^5.0.2"
  },
  "dependencies": {
    "axios": "^1.3.4",
    "form-data": "^4.0.0"
  },
  "jest": {
    "preset": "ts-jest",
    "testEnvironment": "node",
    "testMatch": ["**/*.test.ts"]
  }
}

Build Process

# Install dependencies
npm install
 
# Build the project
npm run build
 
# Run tests
npm test
 
# Lint code
npm run lint
 
# Format code
npm run format

Deployment and Distribution

Local Installation

# Build the node
npm run build
 
# Link for local development
npm link
 
# In n8n directory
cd ~/.n8n
npm link n8n-nodes-canvelete-custom
 
# Restart n8n
n8n start

NPM Publishing

# Login to npm
npm login
 
# Publish to npm registry
npm publish
 
# Or publish with specific tag
npm publish --tag beta

Private Registry

For enterprise deployments:

# Configure private registry
npm config set registry https://your-private-registry.com
 
# Publish to private registry
npm publish

Community Guidelines

Contributing to the Official Node

If you develop useful features, consider contributing back:

  1. Fork the Repository

    git clone https://github.com/amanuel-1/canvelete.git
    cd canvelete/n8n-nodes-canvelete
  2. Create Feature Branch

    git checkout -b feature/your-feature-name
  3. Implement Changes

    • Follow existing code patterns
    • Add comprehensive tests
    • Update documentation
  4. Submit Pull Request

    • Describe the feature and use case
    • Include tests and documentation
    • Follow the contribution guidelines

Sharing Custom Nodes

Share your custom nodes with the community:

  1. Documentation

    • Create comprehensive README
    • Include usage examples
    • Document all parameters
  2. Testing

    • Provide unit tests
    • Include integration tests
    • Test with different n8n versions
  3. Community Channels

    • Share in n8n community forum
    • Post in Canvelete community
    • Create GitHub repository

Best Practices

  1. Code Quality

    • Follow TypeScript best practices
    • Use consistent naming conventions
    • Implement proper error handling
  2. User Experience

    • Provide clear parameter descriptions
    • Use appropriate input validation
    • Include helpful error messages
  3. Performance

    • Implement efficient API calls
    • Use appropriate caching strategies
    • Handle rate limiting gracefully
  4. Security

    • Validate all inputs
    • Use secure credential storage
    • Follow OAuth2 best practices

Support and Resources

Documentation

Community

Support

  • Email: support@canvelete.com
  • Response Time: 24-48 hours for development questions
  • Include: Node version, n8n version, error logs, code samples

Troubleshooting

Common Issues

Node Not Loading

# Clear n8n cache
rm -rf ~/.n8n/cache
 
# Restart n8n
n8n start

TypeScript Compilation Errors

# Check TypeScript version compatibility
npm ls typescript
 
# Update dependencies
npm update

Authentication Issues

# Test credentials manually
curl -H "Authorization: Bearer YOUR_TOKEN" \
  https://canvelete.com/api/automation/designs

Performance Issues

  • Implement request batching
  • Add appropriate delays
  • Use connection pooling
  • Monitor memory usage

This comprehensive guide provides everything needed to develop, test, and deploy custom n8n nodes for Canvelete, enabling advanced automation scenarios and enterprise integrations.