mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-12 16:10:30 +02:00
feat(core): Add webhook-lifecycle-complete ESLint rule for community nodes (no-changelog) (#28778)
This commit is contained in:
parent
13dbcf9bbb
commit
76a558f550
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
```
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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`' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -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(', '),
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user