243 lines
8.1 KiB
JavaScript
243 lines
8.1 KiB
JavaScript
/**
|
|
* ESLint Rule: Server Actions Interface Compliance
|
|
*
|
|
* Ensures server actions follow a specific interface pattern:
|
|
* - Must be async functions
|
|
* - Must use mutations for business logic
|
|
* - Must handle errors properly using Result type
|
|
* - Should not contain business logic directly
|
|
*/
|
|
|
|
module.exports = {
|
|
meta: {
|
|
type: 'problem',
|
|
docs: {
|
|
description: 'Ensure server actions implement the correct interface pattern',
|
|
category: 'Server Actions',
|
|
recommended: true,
|
|
},
|
|
messages: {
|
|
mustBeAsync: 'Server actions must be async functions',
|
|
mustUseMutations: 'Server actions must use mutations for business logic',
|
|
mustHandleErrors: 'Server actions must handle errors using Result type',
|
|
noBusinessLogic: 'Server actions should be thin wrappers, business logic belongs in mutations',
|
|
invalidActionPattern: 'Server actions should follow pattern: async function actionName(input) { const result = await mutation.execute(input); return result; }',
|
|
missingMutationUsage: 'Server action must instantiate and call a mutation',
|
|
},
|
|
schema: [],
|
|
},
|
|
|
|
create(context) {
|
|
const filename = context.getFilename();
|
|
const isServerActionFile = filename.includes('/app/') &&
|
|
(filename.endsWith('.ts') || filename.endsWith('.tsx')) &&
|
|
!filename.endsWith('.test.ts') &&
|
|
!filename.endsWith('.test.tsx');
|
|
|
|
if (!isServerActionFile) {
|
|
return {};
|
|
}
|
|
|
|
let hasUseServerDirective = false;
|
|
const serverActionFunctions = [];
|
|
|
|
return {
|
|
// Check for 'use server' directive
|
|
ExpressionStatement(node) {
|
|
if (node.expression.type === 'Literal' && node.expression.value === 'use server') {
|
|
hasUseServerDirective = true;
|
|
}
|
|
},
|
|
|
|
// Track async function declarations
|
|
FunctionDeclaration(node) {
|
|
if (node.async && node.id && node.id.name) {
|
|
serverActionFunctions.push({
|
|
name: node.id.name,
|
|
node: node,
|
|
body: node.body,
|
|
params: node.params,
|
|
});
|
|
}
|
|
},
|
|
|
|
// Track async arrow function exports
|
|
ExportNamedDeclaration(node) {
|
|
if (node.declaration && node.declaration.type === 'FunctionDeclaration') {
|
|
if (node.declaration.async && node.declaration.id) {
|
|
serverActionFunctions.push({
|
|
name: node.declaration.id.name,
|
|
node: node.declaration,
|
|
body: node.declaration.body,
|
|
params: node.declaration.params,
|
|
});
|
|
}
|
|
} else if (node.declaration && node.declaration.type === 'VariableDeclaration') {
|
|
node.declaration.declarations.forEach(decl => {
|
|
if (decl.init && decl.init.type === 'ArrowFunctionExpression' && decl.init.async) {
|
|
serverActionFunctions.push({
|
|
name: decl.id.name,
|
|
node: decl.init,
|
|
body: decl.init.body,
|
|
params: decl.init.params,
|
|
});
|
|
}
|
|
});
|
|
}
|
|
},
|
|
|
|
'Program:exit'() {
|
|
// Only check files with 'use server' directive
|
|
if (!hasUseServerDirective) return;
|
|
|
|
// Check each server action function
|
|
serverActionFunctions.forEach(func => {
|
|
// Check if function is async
|
|
if (!func.node.async) {
|
|
context.report({
|
|
node: func.node,
|
|
messageId: 'mustBeAsync',
|
|
});
|
|
}
|
|
|
|
// Analyze function body for mutation usage and error handling
|
|
const bodyAnalysis = analyzeFunctionBody(func.body);
|
|
|
|
if (!bodyAnalysis.hasMutationUsage) {
|
|
context.report({
|
|
node: func.node,
|
|
messageId: 'missingMutationUsage',
|
|
});
|
|
}
|
|
|
|
if (!bodyAnalysis.hasErrorHandling) {
|
|
context.report({
|
|
node: func.node,
|
|
messageId: 'mustHandleErrors',
|
|
});
|
|
}
|
|
|
|
if (bodyAnalysis.hasBusinessLogic) {
|
|
context.report({
|
|
node: func.node,
|
|
messageId: 'noBusinessLogic',
|
|
});
|
|
}
|
|
|
|
// Check if function follows the expected pattern
|
|
if (!bodyAnalysis.followsPattern) {
|
|
context.report({
|
|
node: func.node,
|
|
messageId: 'invalidActionPattern',
|
|
});
|
|
}
|
|
});
|
|
},
|
|
};
|
|
},
|
|
};
|
|
|
|
/**
|
|
* Analyze function body to check for proper patterns
|
|
*/
|
|
function analyzeFunctionBody(body) {
|
|
const analysis = {
|
|
hasMutationUsage: false,
|
|
hasErrorHandling: false,
|
|
hasBusinessLogic: false,
|
|
followsPattern: false,
|
|
};
|
|
|
|
if (!body || body.type !== 'BlockStatement') {
|
|
return analysis;
|
|
}
|
|
|
|
const statements = body.body;
|
|
let hasMutationInstantiation = false;
|
|
let hasMutationExecute = false;
|
|
let hasResultCheck = false;
|
|
let hasReturnResult = false;
|
|
|
|
// Check for mutation usage in a more flexible way
|
|
statements.forEach(stmt => {
|
|
// Look for: const mutation = new SomeMutation()
|
|
if (stmt.type === 'VariableDeclaration') {
|
|
stmt.declarations.forEach(decl => {
|
|
if (decl.init && decl.init.type === 'NewExpression') {
|
|
if (decl.init.callee.type === 'Identifier' &&
|
|
decl.init.callee.name.endsWith('Mutation')) {
|
|
hasMutationInstantiation = true;
|
|
}
|
|
}
|
|
|
|
// Look for: const result = await mutation.execute(...)
|
|
if (decl.init && decl.init.type === 'AwaitExpression') {
|
|
const awaitExpr = decl.init.argument;
|
|
if (awaitExpr.type === 'CallExpression') {
|
|
const callee = awaitExpr.callee;
|
|
if (callee.type === 'MemberExpression' &&
|
|
callee.property.type === 'Identifier' &&
|
|
callee.property.name === 'execute') {
|
|
// Check if the object is a mutation or result of mutation
|
|
if (callee.object.type === 'Identifier') {
|
|
const objName = callee.object.name;
|
|
// Could be 'mutation' or 'result' (from mutation.execute)
|
|
if (objName === 'mutation' || objName === 'result' || objName.endsWith('Mutation')) {
|
|
hasMutationExecute = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Look for error handling: if (result.isErr())
|
|
if (stmt.type === 'IfStatement') {
|
|
if (stmt.test.type === 'CallExpression') {
|
|
const callee = stmt.test.callee;
|
|
if (callee.type === 'MemberExpression' &&
|
|
callee.property.type === 'Identifier' &&
|
|
(callee.property.name === 'isErr' || callee.property.name === 'isOk')) {
|
|
hasResultCheck = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Look for return statements that return Result
|
|
if (stmt.type === 'ReturnStatement' && stmt.argument) {
|
|
if (stmt.argument.type === 'CallExpression') {
|
|
const callee = stmt.argument.callee;
|
|
if (callee.type === 'MemberExpression' &&
|
|
callee.object.type === 'Identifier' &&
|
|
callee.object.name === 'Result') {
|
|
hasReturnResult = true;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
analysis.hasMutationUsage = hasMutationInstantiation && hasMutationExecute;
|
|
analysis.hasErrorHandling = hasResultCheck;
|
|
analysis.hasReturnResult = hasReturnResult;
|
|
|
|
// Check if follows pattern: mutation instantiation → execute → error handling → return Result
|
|
analysis.followsPattern = analysis.hasMutationUsage &&
|
|
analysis.hasErrorHandling &&
|
|
analysis.hasReturnResult;
|
|
|
|
// Check for business logic (direct service calls, complex calculations, etc.)
|
|
const hasDirectServiceCall = statements.some(stmt => {
|
|
if (stmt.type === 'ExpressionStatement' && stmt.expression.type === 'CallExpression') {
|
|
const callee = stmt.expression.callee;
|
|
if (callee.type === 'MemberExpression' && callee.object.type === 'Identifier') {
|
|
return callee.object.name.endsWith('Service');
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
|
|
analysis.hasBusinessLogic = hasDirectServiceCall;
|
|
|
|
return analysis;
|
|
} |