From 76a558f55034667c1053ef4c5388de5042dc2a87 Mon Sep 17 00:00:00 2001 From: Garrit Franke <32395585+garritfra@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:28:09 +0200 Subject: [PATCH] feat(core): Add `webhook-lifecycle-complete` ESLint rule for community nodes (no-changelog) (#28778) --- .../eslint-plugin-community-nodes/README.md | 1 + .../rules/require-community-node-keyword.md | 6 +- .../docs/rules/webhook-lifecycle-complete.md | 88 ++++++++ .../package.json | 4 +- .../src/plugin.ts | 2 + .../src/rules/index.ts | 2 + .../rules/webhook-lifecycle-complete.test.ts | 212 ++++++++++++++++++ .../src/rules/webhook-lifecycle-complete.ts | 120 ++++++++++ 8 files changed, 430 insertions(+), 5 deletions(-) create mode 100644 packages/@n8n/eslint-plugin-community-nodes/docs/rules/webhook-lifecycle-complete.md create mode 100644 packages/@n8n/eslint-plugin-community-nodes/src/rules/webhook-lifecycle-complete.test.ts create mode 100644 packages/@n8n/eslint-plugin-community-nodes/src/rules/webhook-lifecycle-complete.ts diff --git a/packages/@n8n/eslint-plugin-community-nodes/README.md b/packages/@n8n/eslint-plugin-community-nodes/README.md index fd5d799a996..b96c562e242 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/README.md +++ b/packages/@n8n/eslint-plugin-community-nodes/README.md @@ -70,5 +70,6 @@ export default [ | [require-node-api-error](docs/rules/require-node-api-error.md) | Require NodeApiError or NodeOperationError for error wrapping in catch blocks. Raw errors lose HTTP context in the n8n UI. | ✅ ☑️ | | | | | | [require-node-description-fields](docs/rules/require-node-description-fields.md) | Node class description must define all required fields: icon, subtitle | ✅ ☑️ | | | | | | [resource-operation-pattern](docs/rules/resource-operation-pattern.md) | Enforce proper resource/operation pattern for better UX in n8n nodes | | ✅ ☑️ | | | | +| [webhook-lifecycle-complete](docs/rules/webhook-lifecycle-complete.md) | Require webhook trigger nodes to implement the complete webhookMethods lifecycle (checkExists, create, delete) | ✅ ☑️ | | | | | diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/require-community-node-keyword.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/require-community-node-keyword.md index 09f30327aec..805241f5816 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/require-community-node-keyword.md +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/require-community-node-keyword.md @@ -1,8 +1,8 @@ -# Require the "n8n-community-node-package" keyword in package.json (`@n8n/community-nodes/require-community-node-keyword`) +# Require the "n8n-community-node-package" keyword in community node package.json (`@n8n/community-nodes/require-community-node-keyword`) -⚠️ This rule is set to `warn` in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. +⚠️ This rule _warns_ in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. -🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/use/command-line-interface#--fix). +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). diff --git a/packages/@n8n/eslint-plugin-community-nodes/docs/rules/webhook-lifecycle-complete.md b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/webhook-lifecycle-complete.md new file mode 100644 index 00000000000..d6898699310 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/docs/rules/webhook-lifecycle-complete.md @@ -0,0 +1,88 @@ +# Require webhook trigger nodes to implement the complete webhookMethods lifecycle (checkExists, create, delete) (`@n8n/community-nodes/webhook-lifecycle-complete`) + +💼 This rule is enabled in the following configs: ✅ `recommended`, ☑️ `recommendedWithoutN8nCloudSupport`. + + + +## Rule Details + +Webhook-based trigger nodes must implement a complete webhook lifecycle so that +n8n can register, verify, and clean up webhooks on the third-party service. +Missing any of the three methods results in leaked webhooks, duplicated +registrations, or workflows that silently stop firing. + +This rule applies to node classes that: + +- declare a non-empty `webhooks` array in their `description`, or +- define a `webhookMethods` class property. + +For every webhook group inside `webhookMethods` (typically `default`), the +methods `checkExists`, `create`, and `delete` must all be implemented. + +Polling triggers (trigger nodes without a `webhooks` array and without +`webhookMethods`) are intentionally out of scope. + +## Examples + +### ❌ Incorrect + +Webhook trigger without any lifecycle methods: + +```typescript +export class MyTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'My Trigger', + name: 'myTrigger', + group: ['trigger'], + version: 1, + description: 'Trigger on events', + defaults: { name: 'My Trigger' }, + inputs: [], + outputs: ['main'], + webhooks: [ + { name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' }, + ], + properties: [], + }; +} +``` + +Webhook trigger with an incomplete `webhookMethods.default`: + +```typescript +export class MyTrigger implements INodeType { + description: INodeTypeDescription = { /* ... */ }; + + webhookMethods = { + default: { + async create(this: IHookFunctions): Promise { /* ... */ return true; }, + // Missing checkExists and delete + }, + }; +} +``` + +### ✅ Correct + +```typescript +export class MyTrigger implements INodeType { + description: INodeTypeDescription = { /* ... */ }; + + webhookMethods = { + default: { + async checkExists(this: IHookFunctions): Promise { + // Return true if the webhook is already registered on the third-party service. + return true; + }, + async create(this: IHookFunctions): Promise { + // Register the webhook with the third-party service and persist any IDs. + return true; + }, + async delete(this: IHookFunctions): Promise { + // Remove the webhook from the third-party service. + return true; + }, + }, + }; +} +``` diff --git a/packages/@n8n/eslint-plugin-community-nodes/package.json b/packages/@n8n/eslint-plugin-community-nodes/package.json index 1b2ecc7dcd5..1084b914ae8 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/package.json +++ b/packages/@n8n/eslint-plugin-community-nodes/package.json @@ -12,14 +12,14 @@ }, "scripts": { "build": "tsc --project tsconfig.build.json", - "build:docs": "eslint-doc-generator", + "build:docs": "pnpm build && eslint-doc-generator", "clean": "rimraf dist .turbo", "dev": "pnpm watch", "format": "biome format --write .", "format:check": "biome ci .", "lint": "eslint src", "lint:fix": "eslint src --fix", - "lint:docs": "eslint-doc-generator --check", + "lint:docs": "pnpm build && eslint-doc-generator --check", "test": "vitest run", "test:unit": "vitest run", "test:dev": "vitest", diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts index 25ed1393d94..b7210d8e19f 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/plugin.ts @@ -43,6 +43,7 @@ const configs = { '@n8n/community-nodes/require-continue-on-fail': 'error', '@n8n/community-nodes/require-node-api-error': 'error', '@n8n/community-nodes/require-node-description-fields': 'error', + '@n8n/community-nodes/webhook-lifecycle-complete': 'error', }, }, recommendedWithoutN8nCloudSupport: { @@ -72,6 +73,7 @@ const configs = { '@n8n/community-nodes/require-continue-on-fail': 'error', '@n8n/community-nodes/require-node-api-error': 'error', '@n8n/community-nodes/require-node-description-fields': 'error', + '@n8n/community-nodes/webhook-lifecycle-complete': 'error', }, }, } satisfies Record; diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts index 8a7aa9e248d..4e9e320af19 100644 --- a/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/index.ts @@ -24,6 +24,7 @@ import { RequireContinueOnFailRule } from './require-continue-on-fail.js'; import { RequireNodeApiErrorRule } from './require-node-api-error.js'; import { RequireNodeDescriptionFieldsRule } from './require-node-description-fields.js'; import { ResourceOperationPatternRule } from './resource-operation-pattern.js'; +import { WebhookLifecycleCompleteRule } from './webhook-lifecycle-complete.js'; export const rules = { 'ai-node-package-json': AiNodePackageJsonRule, @@ -50,4 +51,5 @@ export const rules = { 'require-continue-on-fail': RequireContinueOnFailRule, 'require-node-api-error': RequireNodeApiErrorRule, 'require-node-description-fields': RequireNodeDescriptionFieldsRule, + 'webhook-lifecycle-complete': WebhookLifecycleCompleteRule, } satisfies Record; diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/webhook-lifecycle-complete.test.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/webhook-lifecycle-complete.test.ts new file mode 100644 index 00000000000..7bb10dab3ee --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/webhook-lifecycle-complete.test.ts @@ -0,0 +1,212 @@ +import { RuleTester } from '@typescript-eslint/rule-tester'; + +import { WebhookLifecycleCompleteRule } from './webhook-lifecycle-complete.js'; + +const ruleTester = new RuleTester(); + +function createTriggerNode(options: { + group?: string; + hasWebhooks?: boolean; + webhookMethods?: string | null; +}): string { + const { group = 'trigger', hasWebhooks = true, webhookMethods } = options; + const webhooksProp = hasWebhooks + ? "webhooks: [{ name: 'default', httpMethod: 'POST', responseMode: 'onReceived', path: 'webhook' }]," + : ''; + const methodsProp = webhookMethods === null ? '' : `\n\twebhookMethods = ${webhookMethods};`; + + return ` +import type { INodeType, INodeTypeDescription, IHookFunctions } from 'n8n-workflow'; + +export class TestTrigger implements INodeType { + description: INodeTypeDescription = { + displayName: 'Test Trigger', + name: 'testTrigger', + group: ['${group}'], + version: 1, + description: 'A test trigger', + defaults: { name: 'Test Trigger' }, + inputs: [], + outputs: ['main'], + ${webhooksProp} + properties: [], + };${methodsProp} +}`; +} + +const completeWebhookMethods = `{ + default: { + async checkExists(this: IHookFunctions): Promise { return true; }, + async create(this: IHookFunctions): Promise { return true; }, + async delete(this: IHookFunctions): Promise { return true; }, + }, +}`; + +ruleTester.run('webhook-lifecycle-complete', WebhookLifecycleCompleteRule, { + valid: [ + { + name: 'trigger node with all three webhook lifecycle methods', + code: createTriggerNode({ webhookMethods: completeWebhookMethods }), + }, + { + name: 'non-trigger node without webhookMethods', + code: createTriggerNode({ + group: 'transform', + hasWebhooks: false, + webhookMethods: null, + }), + }, + { + name: 'polling trigger without webhooks array and without webhookMethods', + code: createTriggerNode({ hasWebhooks: false, webhookMethods: null }), + }, + { + name: 'non-INodeType class with webhookMethods (should be ignored)', + code: ` +export class RegularClass { + webhookMethods = { + default: { + async checkExists() { return true; }, + }, + }; +}`, + }, + { + name: 'trigger with multiple webhook groups, all complete', + code: createTriggerNode({ + webhookMethods: `{ + default: { + async checkExists() { return true; }, + async create() { return true; }, + async delete() { return true; }, + }, + setup: { + async checkExists() { return true; }, + async create() { return true; }, + async delete() { return true; }, + }, + }`, + }), + }, + { + name: 'webhook lifecycle defined via arrow functions', + code: createTriggerNode({ + webhookMethods: `{ + default: { + checkExists: async () => true, + create: async () => true, + delete: async () => true, + }, + }`, + }), + }, + ], + invalid: [ + { + name: 'trigger node missing webhookMethods entirely', + code: createTriggerNode({ webhookMethods: null }), + errors: [{ messageId: 'missingWebhookMethods' }], + }, + { + name: 'trigger node with empty webhookMethods group (all three missing)', + code: createTriggerNode({ + webhookMethods: `{ + default: {}, + }`, + }), + errors: [ + { + messageId: 'missingLifecycleMethod', + data: { + group: 'default', + missing: '`checkExists`, `create`, `delete`', + }, + }, + ], + }, + { + name: 'trigger node missing only delete', + code: createTriggerNode({ + webhookMethods: `{ + default: { + async checkExists() { return true; }, + async create() { return true; }, + }, + }`, + }), + errors: [ + { + messageId: 'missingLifecycleMethod', + data: { group: 'default', missing: '`delete`' }, + }, + ], + }, + { + name: 'trigger node missing checkExists and create', + code: createTriggerNode({ + webhookMethods: `{ + default: { + async delete() { return true; }, + }, + }`, + }), + errors: [ + { + messageId: 'missingLifecycleMethod', + data: { group: 'default', missing: '`checkExists`, `create`' }, + }, + ], + }, + { + name: 'non-trigger node with incomplete webhookMethods (still flagged)', + code: createTriggerNode({ + group: 'transform', + hasWebhooks: false, + webhookMethods: `{ + default: { + async create() { return true; }, + }, + }`, + }), + errors: [ + { + messageId: 'missingLifecycleMethod', + data: { group: 'default', missing: '`checkExists`, `delete`' }, + }, + ], + }, + { + name: 'webhook trigger (detected via webhooks array) missing webhookMethods', + code: createTriggerNode({ + group: 'transform', + hasWebhooks: true, + webhookMethods: null, + }), + errors: [{ messageId: 'missingWebhookMethods' }], + }, + { + name: 'multiple webhook groups each missing methods', + code: createTriggerNode({ + webhookMethods: `{ + default: { + async checkExists() { return true; }, + }, + setup: { + async create() { return true; }, + async delete() { return true; }, + }, + }`, + }), + errors: [ + { + messageId: 'missingLifecycleMethod', + data: { group: 'default', missing: '`create`, `delete`' }, + }, + { + messageId: 'missingLifecycleMethod', + data: { group: 'setup', missing: '`checkExists`' }, + }, + ], + }, + ], +}); diff --git a/packages/@n8n/eslint-plugin-community-nodes/src/rules/webhook-lifecycle-complete.ts b/packages/@n8n/eslint-plugin-community-nodes/src/rules/webhook-lifecycle-complete.ts new file mode 100644 index 00000000000..475d086e966 --- /dev/null +++ b/packages/@n8n/eslint-plugin-community-nodes/src/rules/webhook-lifecycle-complete.ts @@ -0,0 +1,120 @@ +import { AST_NODE_TYPES, type TSESTree } from '@typescript-eslint/utils'; + +import { + createRule, + findClassProperty, + findObjectProperty, + isNodeTypeClass, +} from '../utils/index.js'; + +const REQUIRED_METHODS = ['checkExists', 'create', 'delete'] as const; +type RequiredMethod = (typeof REQUIRED_METHODS)[number]; + +/** + * Returns true if the description declares webhook endpoints, indicating the + * node is a webhook-based trigger that needs a complete lifecycle. + * + * Polling triggers (group `['trigger']` without a `webhooks` array) do not + * register remote webhooks and are intentionally out of scope. + */ +function hasWebhooksDeclared(descriptionValue: TSESTree.ObjectExpression): boolean { + const webhooksProperty = findObjectProperty(descriptionValue, 'webhooks'); + if (webhooksProperty?.value.type !== AST_NODE_TYPES.ArrayExpression) return false; + return webhooksProperty.value.elements.length > 0; +} + +/** Returns true when the property defines a (possibly async) method named `name`. */ +function isMethodProperty(property: TSESTree.ObjectLiteralElement, name: string): boolean { + if (property.type !== AST_NODE_TYPES.Property) return false; + if (property.computed) return false; + + const keyMatches = + (property.key.type === AST_NODE_TYPES.Identifier && property.key.name === name) || + (property.key.type === AST_NODE_TYPES.Literal && property.key.value === name); + if (!keyMatches) return false; + + return ( + property.value.type === AST_NODE_TYPES.FunctionExpression || + property.value.type === AST_NODE_TYPES.ArrowFunctionExpression + ); +} + +function findMissingMethods(group: TSESTree.ObjectExpression): RequiredMethod[] { + return REQUIRED_METHODS.filter( + (method) => !group.properties.some((property) => isMethodProperty(property, method)), + ); +} + +export const WebhookLifecycleCompleteRule = createRule({ + name: 'webhook-lifecycle-complete', + meta: { + type: 'problem', + docs: { + description: + 'Require webhook trigger nodes to implement the complete webhookMethods lifecycle (checkExists, create, delete)', + }, + messages: { + missingWebhookMethods: + 'Webhook trigger node is missing the `webhookMethods` property. Implement `checkExists`, `create`, and `delete` to register, verify, and clean up the webhook on the third-party service.', + missingLifecycleMethod: + 'Webhook trigger lifecycle is incomplete. `webhookMethods.{{group}}` is missing: {{missing}}. All of `checkExists`, `create`, and `delete` must be implemented.', + }, + schema: [], + }, + defaultOptions: [], + create(context) { + return { + ClassDeclaration(node) { + if (!isNodeTypeClass(node)) return; + + const descriptionProperty = findClassProperty(node, 'description'); + if (!descriptionProperty) return; + + const descriptionValue = descriptionProperty.value; + if (descriptionValue?.type !== AST_NODE_TYPES.ObjectExpression) return; + + const webhookMethodsProperty = findClassProperty(node, 'webhookMethods'); + + if (!hasWebhooksDeclared(descriptionValue) && !webhookMethodsProperty) { + return; + } + + if (!webhookMethodsProperty?.value) { + context.report({ + node: node.id ?? node, + messageId: 'missingWebhookMethods', + }); + return; + } + + if (webhookMethodsProperty.value.type !== AST_NODE_TYPES.ObjectExpression) { + return; + } + + for (const groupProperty of webhookMethodsProperty.value.properties) { + if (groupProperty.type !== AST_NODE_TYPES.Property) continue; + if (groupProperty.value.type !== AST_NODE_TYPES.ObjectExpression) continue; + + const groupName = + groupProperty.key.type === AST_NODE_TYPES.Identifier + ? groupProperty.key.name + : groupProperty.key.type === AST_NODE_TYPES.Literal + ? String(groupProperty.key.value) + : 'default'; + + const missing = findMissingMethods(groupProperty.value); + if (missing.length === 0) continue; + + context.report({ + node: groupProperty.key, + messageId: 'missingLifecycleMethod', + data: { + group: groupName, + missing: missing.map((m) => `\`${m}\``).join(', '), + }, + }); + } + }, + }; + }, +});