@apito-io/js-apito-plugin-sdk
Version:
JavaScript SDK for building Apito HashiCorp plugins
865 lines (755 loc) • 31.5 kB
JavaScript
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const path = require('path');
const fs = require('fs');
/**
* Apito JavaScript Plugin SDK
*
* A simplified SDK for building HashiCorp plugins for the Apito Engine.
* This SDK abstracts away all the boilerplate code and provides a clean,
* easy-to-use interface for plugin developers.
*/
// Type definitions for better documentation
/**
* @typedef {Object} GraphQLField
* @property {Object} type - GraphQL type definition
* @property {string} description - Field description
* @property {Object} [args] - Field arguments
* @property {string} resolve - Resolver function name
*/
/**
* @typedef {Object} RESTEndpoint
* @property {string} method - HTTP method (GET, POST, etc.)
* @property {string} path - Endpoint path
* @property {string} description - Endpoint description
* @property {Object} [schema] - Request/response schema
* @property {string} handler - Handler function name
*/
/**
* @typedef {Function} ResolverFunc
* @param {Object} context - Request context
* @param {Object} args - Function arguments
* @returns {Promise<any>} Resolver result
*/
/**
* @typedef {Function} RESTHandlerFunc
* @param {Object} context - Request context
* @param {Object} args - Function arguments
* @returns {Promise<any>} Handler result
*/
/**
* @typedef {Function} FunctionHandlerFunc
* @param {Object} context - Request context
* @param {Object} args - Function arguments
* @returns {Promise<any>} Function result
*/
/**
* Main Plugin class that handles all plugin functionality
*/
class Plugin {
constructor(name, version, apiKey) {
this.name = name;
this.version = version;
this.apiKey = apiKey;
// Registry maps
this.queries = new Map();
this.mutations = new Map();
this.restAPIs = [];
this.resolvers = new Map();
this.restHandlers = new Map();
this.functions = new Map();
this.healthChecks = [];
// gRPC server instance
this.server = null;
this.protoDefinition = null;
// Auto-register health check
this.registerFunction('health_check', this.performHealthCheck.bind(this));
}
/**
* Register a GraphQL query
* @param {string} name - Query name
* @param {GraphQLField} field - Field definition
* @param {ResolverFunc} resolver - Resolver function
*/
registerQuery(name, field, resolver) {
field.resolve = name;
this.queries.set(name, field);
this.resolvers.set(name, resolver);
// Use stderr for plugin logs to avoid interfering with stdout handshake
process.stderr.write(`SDK: Registered query '${name}'\n`);
}
/**
* Register a GraphQL mutation
* @param {string} name - Mutation name
* @param {GraphQLField} field - Field definition
* @param {ResolverFunc} resolver - Resolver function
*/
registerMutation(name, field, resolver) {
field.resolve = name;
this.mutations.set(name, field);
this.resolvers.set(name, resolver);
process.stderr.write(`SDK: Registered mutation '${name}'\n`);
}
/**
* Register multiple queries at once
* @param {Object<string, GraphQLField>} queries - Queries map
* @param {Object<string, ResolverFunc>} resolvers - Resolvers map
*/
registerQueries(queries, resolvers) {
for (const [name, field] of Object.entries(queries)) {
if (resolvers[name]) {
this.registerQuery(name, field, resolvers[name]);
}
}
}
/**
* Register multiple mutations at once
* @param {Object<string, GraphQLField>} mutations - Mutations map
* @param {Object<string, ResolverFunc>} resolvers - Resolvers map
*/
registerMutations(mutations, resolvers) {
for (const [name, field] of Object.entries(mutations)) {
if (resolvers[name]) {
this.registerMutation(name, field, resolvers[name]);
}
}
}
/**
* Register a REST API endpoint
* @param {RESTEndpoint} endpoint - Endpoint definition
* @param {RESTHandlerFunc} handler - Handler function
*/
registerRESTAPI(endpoint, handler) {
endpoint.handler = `${endpoint.method}_${endpoint.path}`;
this.restAPIs.push(endpoint);
this.restHandlers.set(endpoint.handler, handler);
process.stderr.write(`SDK: Registered REST API ${endpoint.method} ${endpoint.path}\n`);
}
/**
* Register multiple REST APIs at once
* @param {RESTEndpoint[]} endpoints - Endpoints array
* @param {Object<string, RESTHandlerFunc>} handlers - Handlers map
*/
registerRESTAPIs(endpoints, handlers) {
for (const endpoint of endpoints) {
const handlerKey = `${endpoint.method}_${endpoint.path}`;
if (handlers[handlerKey]) {
this.registerRESTAPI(endpoint, handlers[handlerKey]);
}
}
}
/**
* Register a custom function
* @param {string} name - Function name
* @param {FunctionHandlerFunc} func - Function implementation
*/
registerFunction(name, func) {
this.functions.set(name, func);
process.stderr.write(`SDK: Registered function '${name}'\n`);
}
/**
* Register multiple functions at once
* @param {Object<string, FunctionHandlerFunc>} functions - Functions map
*/
registerFunctions(functions) {
for (const [name, func] of Object.entries(functions)) {
this.registerFunction(name, func);
}
}
/**
* Register a health check function
* @param {Function} healthCheck - Health check function
*/
registerHealthCheck(healthCheck) {
this.healthChecks.push(healthCheck);
process.stderr.write(`SDK: Registered health check\n`);
}
/**
* Built-in health check implementation
* @param {Object} context - Request context
* @returns {Object} Health status
*/
async performHealthCheck(context) {
const healthInfo = {
status: 'healthy',
plugin_id: this.name,
version: this.version,
timestamp: new Date().toISOString(),
uptime: process.uptime(),
memory_usage: process.memoryUsage(),
capabilities: {
graphql_queries: this.queries.size > 0,
graphql_mutations: this.mutations.size > 0,
rest_endpoints: this.restAPIs.length > 0,
custom_functions: this.functions.size > 0,
health_checks: this.healthChecks.length > 0
},
environment: {
pid: process.pid,
node_version: process.version,
platform: process.platform,
arch: process.arch
}
};
// Run custom health checks
const customHealthResults = {};
let overallStatus = 'healthy';
for (let i = 0; i < this.healthChecks.length; i++) {
const checkName = `custom_check_${i}`;
try {
const checkResult = await this.healthChecks[i](context);
customHealthResults[checkName] = checkResult;
if (checkResult.status && checkResult.status !== 'healthy') {
overallStatus = 'degraded';
}
} catch (error) {
customHealthResults[checkName] = {
status: 'error',
error: error.message
};
overallStatus = 'degraded';
}
}
if (Object.keys(customHealthResults).length > 0) {
healthInfo.custom_health_checks = customHealthResults;
}
healthInfo.status = overallStatus;
return healthInfo;
}
/**
* Start the plugin server
*/
async serve() {
// Validate magic cookie first
if (!process.env.APITO_PLUGIN || process.env.APITO_PLUGIN !== 'apito_plugin_magic_cookie_v1') {
process.stderr.write('SDK: ERROR - Magic cookie not set or invalid. Expected APITO_PLUGIN=apito_plugin_magic_cookie_v1\n');
process.exit(1);
}
try {
// Load protobuf definition
await this.loadProtoDefinition();
// Create gRPC server
this.server = new grpc.Server();
// Access the service from the proper package structure
const pluginService = this.protoDefinition.apito?.plugin?.v1?.PluginService ||
this.protoDefinition.PluginService;
if (!pluginService) {
throw new Error('PluginService not found in protobuf definition. Available services: ' +
Object.keys(this.protoDefinition));
}
// Add plugin service
this.server.addService(pluginService.service, {
Init: this.handleInit.bind(this),
Migration: this.handleMigration.bind(this),
SchemaRegister: this.handleSchemaRegister.bind(this),
RESTApiRegister: this.handleRESTApiRegister.bind(this),
GetVersion: this.handleGetVersion.bind(this),
Execute: this.handleExecute.bind(this)
});
// Start server
await this.startServer();
} catch (error) {
process.stderr.write(`SDK: Failed to start plugin server: ${error}\n`);
process.exit(1);
}
}
/**
* Load protobuf definition
*/
async loadProtoDefinition() {
// Look for plugin.proto in various locations
const protoPaths = [
path.join(__dirname, 'plugin.proto'), // SDK package root
path.join(__dirname, '..', 'plugin.proto'), // Parent directory
path.join(__dirname, '..', '..', 'plugin.proto'), // Two levels up
path.join(__dirname, '..', 'buffers', 'plugin.proto'), // Buffers sibling
path.join(process.cwd(), 'plugin.proto'), // Current working directory
'/Users/diablo/go/src/gitlab.com/apito.io/buffers/plugin.proto' // Absolute path
];
let protoPath = null;
for (const testPath of protoPaths) {
if (fs.existsSync(testPath)) {
protoPath = testPath;
break;
}
}
if (!protoPath) {
throw new Error('Could not find plugin.proto file');
}
process.stderr.write(`SDK: Loading proto definition from: ${protoPath}\n`);
const packageDefinition = protoLoader.loadSync(protoPath, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true
});
this.protoDefinition = grpc.loadPackageDefinition(packageDefinition);
}
/**
* Start the gRPC server
*/
async startServer() {
return new Promise((resolve, reject) => {
const port = process.env.PLUGIN_GRPC_PORT || '0';
const address = `127.0.0.1:${port}`;
// Use insecure credentials for now (AutoMTLS disabled for JS plugins)
this.server.bindAsync(address, grpc.ServerCredentials.createInsecure(), (err, assignedPort) => {
if (err) {
reject(err);
return;
}
// Start the server
this.server.start();
process.stderr.write(`SDK: Plugin server listening on port ${assignedPort}\n`);
// Output the handshake protocol for go-plugin to stdout (CRITICAL: no other stdout output before this)
process.stdout.write(`1|1|tcp|127.0.0.1:${assignedPort}|grpc\n`);
// Handle graceful shutdown
this.setupGracefulShutdown();
resolve(assignedPort);
});
});
}
/**
* Setup graceful shutdown handlers
*/
setupGracefulShutdown() {
const shutdown = (signal) => {
process.stderr.write(`SDK: Received ${signal}, shutting down gracefully...\n`);
if (this.server) {
this.server.tryShutdown((err) => {
if (err) {
process.stderr.write(`SDK: Error during shutdown: ${err}\n`);
process.exit(1);
}
process.stderr.write(`SDK: Shutdown complete\n`);
process.exit(0);
});
} else {
process.exit(0);
}
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
}
// gRPC Service Handlers
async handleInit(call, callback) {
process.stderr.write(`SDK: Initializing plugin '${this.name}'...\n`);
try {
const request = call.request;
// Set environment variables
if (request.envVars) {
for (const env of request.envVars) {
process.env[env.key] = env.value;
process.stderr.write(`SDK: Set env ${env.key}=${env.value}\n`);
}
}
callback(null, {
success: true,
message: `Plugin '${this.name}' initialized successfully`
});
} catch (error) {
process.stderr.write(`SDK: Init error: ${error}\n`);
callback(null, {
success: false,
message: `Initialization failed: ${error.message}`
});
}
}
async handleMigration(call, callback) {
process.stderr.write(`SDK: Running migration for plugin '${this.name}'...\n`);
try {
callback(null, {
success: true,
message: `No migration needed for plugin '${this.name}'`
});
} catch (error) {
process.stderr.write(`SDK: Migration error: ${error}\n`);
callback(null, {
success: false,
message: `Migration failed: ${error.message}`
});
}
}
async handleSchemaRegister(call, callback) {
process.stderr.write(`SDK: Registering GraphQL schema for plugin '${this.name}'...\n`);
try {
// Convert Maps to objects for serialization
const queriesObj = Object.fromEntries(this.queries);
const mutationsObj = Object.fromEntries(this.mutations);
// Proper protobuf.Struct conversion that handles field definitions with arguments
const convertToProtobufStruct = (data) => {
const fields = {};
for (const [key, value] of Object.entries(data)) {
fields[key] = {
structValue: {
fields: this.convertFieldToProtobuf(value)
}
};
}
return { fields };
};
const schema = {
queries: convertToProtobufStruct(queriesObj),
mutations: convertToProtobufStruct(mutationsObj),
subscriptions: convertToProtobufStruct({})
};
// Debug logging to show schema structure
process.stderr.write(`SDK: DEBUG - Schema structure:\n`);
process.stderr.write(`SDK: DEBUG - Queries: ${JSON.stringify(schema.queries, null, 2)}\n`);
process.stderr.write(`SDK: DEBUG - Mutations: ${JSON.stringify(schema.mutations, null, 2)}\n`);
process.stderr.write(`SDK: Registered ${this.queries.size} queries, ${this.mutations.size} mutations\n`);
callback(null, { schema });
} catch (error) {
process.stderr.write(`SDK: Schema registration error: ${error}\n`);
// Fallback empty schema structure
const emptyStruct = { fields: {} };
callback(null, {
schema: {
queries: emptyStruct,
mutations: emptyStruct,
subscriptions: emptyStruct
}
});
}
}
// Helper method to properly convert field definitions to protobuf format
convertFieldToProtobuf(fieldDef) {
const fields = {};
// Convert basic field properties
if (fieldDef.type) {
fields.type = {
structValue: {
fields: this.convertTypeToProtobuf(fieldDef.type)
}
};
}
if (fieldDef.description) {
fields.description = {
stringValue: fieldDef.description
};
}
// Convert arguments if they exist
if (fieldDef.args && Object.keys(fieldDef.args).length > 0) {
fields.args = {
structValue: {
fields: this.convertArgsToProtobuf(fieldDef.args)
}
};
}
// Add resolve property
if (fieldDef.resolve) {
fields.resolve = {
stringValue: fieldDef.resolve
};
}
return fields;
}
// Helper method to convert argument definitions to protobuf format
convertArgsToProtobuf(args) {
const fields = {};
for (const [argName, argDef] of Object.entries(args)) {
fields[argName] = {
structValue: {
fields: {}
}
};
// Convert argument type
if (argDef.type) {
fields[argName].structValue.fields.type = {
structValue: {
fields: this.convertTypeToProtobuf(argDef.type)
}
};
}
// Convert argument description
if (argDef.description) {
fields[argName].structValue.fields.description = {
stringValue: argDef.description
};
}
// Convert argument properties if they exist (for object types)
// Check both argDef.properties (direct) and argDef.type.fields (ObjectArg structure)
if (argDef.properties) {
fields[argName].structValue.fields.properties = {
structValue: {
fields: this.convertArgsToProtobuf(argDef.properties)
}
};
} else if (argDef.type && argDef.type.fields) {
fields[argName].structValue.fields.properties = {
structValue: {
fields: this.convertArgsToProtobuf(argDef.type.fields)
}
};
}
}
return fields;
}
// Helper method to convert type definitions to protobuf format
convertTypeToProtobuf(typeDef) {
const fields = {};
if (typeof typeDef === 'string') {
// Simple scalar type
fields.name = {
stringValue: typeDef
};
} else if (typeDef && typeof typeDef === 'object') {
// Complex type definition
if (typeDef.name) {
fields.name = {
stringValue: typeDef.name
};
}
if (typeDef.kind) {
fields.kind = {
stringValue: typeDef.kind
};
}
// CRITICAL FIX: Add scalarType field for scalar types
if (typeDef.scalarType) {
fields.scalarType = {
stringValue: typeDef.scalarType
};
}
if (typeDef.ofType) {
fields.ofType = {
structValue: {
fields: this.convertTypeToProtobuf(typeDef.ofType)
}
};
}
// Handle custom object types with fields
if (typeDef.fields) {
fields.fields = {
structValue: {
fields: {}
}
};
for (const [fieldName, fieldDef] of Object.entries(typeDef.fields)) {
fields.fields.structValue.fields[fieldName] = {
structValue: {
fields: this.convertFieldToProtobuf(fieldDef)
}
};
}
}
}
return fields;
}
async handleRESTApiRegister(call, callback) {
process.stderr.write(`SDK: Registering REST APIs for plugin '${this.name}'...\n`);
try {
const apis = this.restAPIs.map(endpoint => ({
method: endpoint.method,
path: endpoint.path,
description: endpoint.description,
schema: endpoint.schema || {}
}));
process.stderr.write(`SDK: Registered ${apis.length} REST endpoints\n`);
callback(null, { apis });
} catch (error) {
process.stderr.write(`SDK: REST API registration error: ${error}\n`);
callback(null, { apis: [] });
}
}
async handleGetVersion(call, callback) {
process.stderr.write(`SDK: Getting version for plugin '${this.name}'...\n`);
try {
callback(null, { version: this.version });
} catch (error) {
process.stderr.write(`SDK: Get version error: ${error}\n`);
callback(null, { version: 'unknown' });
}
}
async handleExecute(call, callback) {
process.stderr.write(`SDK: Executing function...\n`);
try {
const request = call.request;
const functionName = request.function_name;
const functionType = request.function_type;
// Safe debug logging without JSON.stringify of complex objects
process.stderr.write(`SDK: Function: ${functionType}:${functionName}\n`);
process.stderr.write(`SDK: Has args: ${!!request.args}\n`);
process.stderr.write(`SDK: Has context: ${!!request.context}\n`);
const args = request.args ? this.structToObject(request.args) : {};
const context = request.context ? this.structToObject(request.context) : {};
process.stderr.write(`SDK: Args keys: [${Object.keys(args).join(', ')}]\n`);
process.stderr.write(`SDK: Context keys: [${Object.keys(context).join(', ')}]\n`);
let result;
if (functionType === 'graphql_query') {
if (this.resolvers.has(functionName)) {
process.stderr.write(`SDK: Calling query resolver for ${functionName}\n`);
result = await this.resolvers.get(functionName)(context, args);
} else {
throw new Error(`Query resolver not found: ${functionName}`);
}
} else if (functionType === 'graphql_mutation') {
if (this.resolvers.has(functionName)) {
process.stderr.write(`SDK: Calling mutation resolver for ${functionName}\n`);
result = await this.resolvers.get(functionName)(context, args);
} else {
throw new Error(`Mutation resolver not found: ${functionName}`);
}
} else if (functionType === 'rest_api') {
if (this.restHandlers.has(functionName)) {
process.stderr.write(`SDK: Calling REST handler for ${functionName}\n`);
result = await this.restHandlers.get(functionName)(context, args);
} else {
throw new Error(`REST handler not found: ${functionName}`);
}
} else if (functionType === 'custom_function') {
if (this.functions.has(functionName)) {
process.stderr.write(`SDK: Calling custom function for ${functionName}\n`);
result = await this.functions.get(functionName)(context, args);
} else {
throw new Error(`Custom function not found: ${functionName}`);
}
} else if (functionType === 'system') {
// Handle system functions like health checks
process.stderr.write(`SDK: Handling system function: ${functionName}\n`);
if (functionName === 'health_check') {
// Simple health check response
result = {
status: 'healthy',
plugin: this.name,
version: this.version,
timestamp: new Date().toISOString()
};
} else {
throw new Error(`Unknown system function: ${functionName}`);
}
} else {
throw new Error(`Unknown function type: ${functionType}`);
}
process.stderr.write(`SDK: Resolver returned: ${typeof result}\n`);
// Convert result to protobuf format - handle arrays and objects correctly
let protobufResult;
if (Array.isArray(result)) {
// For arrays, convert directly to protobuf value format
protobufResult = this.convertValueToProtobuf(result);
} else if (typeof result === 'object' && result !== null) {
// For objects, use struct format
protobufResult = this.convertToProtobufStruct(result);
} else {
// For primitives, convert to value format
protobufResult = this.convertValueToProtobuf(result);
}
process.stderr.write(`SDK: Converted to protobuf format\n`);
// Determine correct typeUrl based on result type
const typeUrl = Array.isArray(result) || typeof result !== 'object' || result === null
? 'type.googleapis.com/google.protobuf.Value'
: 'type.googleapis.com/google.protobuf.Struct';
callback(null, {
success: true,
result: {
typeUrl: typeUrl,
value: Buffer.from(JSON.stringify(protobufResult))
},
error: ''
});
} catch (error) {
process.stderr.write(`SDK: Error executing function: ${error.message}\n`);
process.stderr.write(`SDK: Error stack: ${error.stack}\n`);
callback(null, {
success: false,
result: null,
error: error.message
});
}
}
// Utility methods
structToObject(struct) {
if (!struct) {
process.stderr.write(`SDK: structToObject received null/undefined\n`);
return {};
}
if (!struct.fields) {
process.stderr.write(`SDK: structToObject received struct without fields\n`);
return {};
}
process.stderr.write(`SDK: Converting struct with ${Object.keys(struct.fields).length} fields: [${Object.keys(struct.fields).join(', ')}]\n`);
const result = {};
for (const [key, value] of Object.entries(struct.fields)) {
process.stderr.write(`SDK: Converting field '${key}'\n`);
result[key] = this.valueToJS(value);
process.stderr.write(`SDK: Field '${key}' -> ${typeof result[key]}\n`);
}
process.stderr.write(`SDK: Converted object keys: [${Object.keys(result).join(', ')}]\n`);
return result;
}
valueToJS(value) {
if (!value) {
return null;
}
if (value.stringValue !== undefined) {
process.stderr.write(`SDK: String: "${value.stringValue}"\n`);
return value.stringValue;
} else if (value.numberValue !== undefined) {
process.stderr.write(`SDK: Number: ${value.numberValue}\n`);
return value.numberValue;
} else if (value.boolValue !== undefined) {
process.stderr.write(`SDK: Boolean: ${value.boolValue}\n`);
return value.boolValue;
} else if (value.structValue !== undefined) {
process.stderr.write(`SDK: Object (recursing)\n`);
return this.structToObject(value.structValue);
} else if (value.listValue !== undefined && value.listValue.values) {
process.stderr.write(`SDK: Array[${value.listValue.values.length}]\n`);
return value.listValue.values.map(v => this.valueToJS(v));
} else if (value.nullValue !== undefined) {
process.stderr.write(`SDK: Null\n`);
return null;
} else {
process.stderr.write(`SDK: Unknown type: [${Object.keys(value).join(', ')}]\n`);
return null;
}
}
convertToProtobufStruct(data) {
const fields = {};
for (const [key, value] of Object.entries(data)) {
fields[key] = this.convertValueToProtobuf(value);
}
return { fields };
}
convertValueToProtobuf(value) {
if (value === null || value === undefined) {
return { nullValue: 0 };
} else if (typeof value === 'string') {
return { stringValue: value };
} else if (typeof value === 'number') {
return { numberValue: value };
} else if (typeof value === 'boolean') {
return { boolValue: value };
} else if (Array.isArray(value)) {
return {
listValue: {
values: value.map(item => this.convertValueToProtobuf(item))
}
};
} else if (typeof value === 'object') {
const fields = {};
for (const [prop, propValue] of Object.entries(value)) {
fields[prop] = this.convertValueToProtobuf(propValue);
}
return { structValue: { fields } };
} else {
// Fallback to string representation
return { stringValue: String(value) };
}
}
}
// Import all helper functions
const helpers = require('./helpers');
// Export the Plugin class, init function, and all helpers
module.exports = {
Plugin,
init: function(name, version, apiKey) {
// Use stderr for initialization logs
process.stderr.write(`SDK: Initializing plugin '${name}' v${version}\n`);
return new Plugin(name, version, apiKey);
},
// Re-export all helper functions
...helpers
};