feat(core): Add webhook-lifecycle-complete ESLint rule for community nodes (no-changelog) (#28778)

This commit is contained in:
Garrit Franke 2026-04-23 11:28:09 +02:00 committed by GitHub
parent 13dbcf9bbb
commit 76a558f550
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 430 additions and 5 deletions

View File

@ -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) | ✅ ☑️ | | | | |
<!-- end auto-generated rules list -->

View File

@ -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).
<!-- end auto-generated rule header -->

View File

@ -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`.
<!-- end auto-generated rule header -->
## 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<boolean> { /* ... */ return true; },
// Missing checkExists and delete
},
};
}
```
### ✅ Correct
```typescript
export class MyTrigger implements INodeType {
description: INodeTypeDescription = { /* ... */ };
webhookMethods = {
default: {
async checkExists(this: IHookFunctions): Promise<boolean> {
// Return true if the webhook is already registered on the third-party service.
return true;
},
async create(this: IHookFunctions): Promise<boolean> {
// Register the webhook with the third-party service and persist any IDs.
return true;
},
async delete(this: IHookFunctions): Promise<boolean> {
// Remove the webhook from the third-party service.
return true;
},
},
};
}
```

View File

@ -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",

View File

@ -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<string, Linter.Config>;

View File

@ -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<string, AnyRuleModule>;

View File

@ -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<boolean> { return true; },
async create(this: IHookFunctions): Promise<boolean> { return true; },
async delete(this: IHookFunctions): Promise<boolean> { 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`' },
},
],
},
],
});

View File

@ -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(', '),
},
});
}
},
};
},
});