Search code examples
eventsversion-controlbackendwebhookscontinuous-deployment

How to allow clients to hook into preconfigured hooks in application


I have a pretty standard application with a frontend, a backend and some options in the frontend for modifying data. My backend fires events when data is modified (eg. record created, record updated, user logged in, etc.).

Now what I want to do is for my customers to be able to code their own functions and "hook" them into these events.

So far the approaches I have thought of are:

  1. Allowing users in the frontend to write some code in a codeeditor like codemirror, but this whole storing code and executing it with some eval() seems kind of risky and unstable.
  2. My second approach is illustrated below (to the best of my ability at least). The point is that the CRUD API calls a different "hook" web service that has these (recordUpdated, recordCreated, userLoggedIn,...) hook methods exposed. Then the client library needs to extend some predefined interfaces for the different hooks I expose. This still seems doable, but my issue is I can't figure out how my customers would deploy their library into the running "hook" service.

So it's kind of like webhooks, except I already know the exact hooks to be created which I figured could allow for an easier setup than customers having to create their own web services from scratch, but instead just create a library that is then deployed into an existing API (or something like that...). Preferably the infrastructure details should be hidden from the customers so they can focus solely on making business logic inside their custom hooks.

It's kind of hard to explain, but hopefully someone will get and can tell me if I'm on the right track or if there is a more standard way of doing hooks like these?

Currently the entire backend is written in C# but that is not a requirement.

enter image description here


Solution

  • I'll just draft out the main framework, then wait for your feedback to fill in anything unclear.

    Disclaimer: I don't really have expertise with security and sandboxing. I just know it's an important thing, but really, it's beyond me. You go figure it out 😂

    Suppose we're now in a safe sandbox where all malicious behaviors are magically taken care, let's write some Node.js code for that "hook engine".

    How users deploy their plugin code.

    Let's assume we use file-base deployment. The interface you need to implement is a PluginRegistry.

    class PluginRegistry {
      constructor() {
        /**
        The plugin registry holds records of plugin info:
    
        type IPluginInfo = {
          userId: string,
          hash: string,
          filePath: string,
          module: null | object,
        }
        */
    
        this.records = new Map()
      }
    
      register(userId, info) {
        this.records.set(userId, info)
      }
    
      query(userId) {
        return this.records.get(userId)
      }
    }
    
    // plugin registry should be a singleton in your app.
    const pluginRegistrySingleton = new PluginRegistry()
    
    // app opens a http endpoint
    // that accepts plugin registration
    // this is how you receive user provided code
    server.listen(port, (req, res) => {
      if (isPluginRegistration(req)) {
        let { userId, fileBytes, hash } = processRequest(req)
        let filePath = pluginDir + '/' + hash + '.js'
    
        let pluginInfo = {
          userId,
          // you should use some kind of hash
          // to uniquely identify plugin
          hash,
          filePath,
          // "module" field is left empty
          // it will be lazy-loaded when
          // plugin code is actually needed
          module: null,
        }
    
        let existingPluginInfo = pluginRegistrySingleton.query(userId)
        if (existingPluginInfo.hash === hash) {
          // already exist, skip
          res.writeHead(200, { 'Content-Type': 'text/plain' });
          res.end('ok');
        } else {
          // plugin code written down somewhere
          fs.writeFile(filePath, fileBytes, (err) => {
            pluginRegistrySingleton.register(userId, pluginInfo)
            res.writeHead(200, { 'Content-Type': 'text/plain' });
            res.end('ok');
          })
        }
    
      }
    })
    

    From the perspective of hook engine, it simply opens a HTTP endpoint to accept plugin registration, agnostic to the source.

    Whether it's from CI/CD pipeline, or plain web interface upload, it doesn't matter. If you have CI/CD setup for your user, it is just a dedicated build machine that runs bash scripts isn't it? So just fire a curl call to this endpoint to upload whatever you need. Same applies to web interface.

    How we would execute plugin code

    User plugin code is just normal Node.js module code. You might instruct them to expose certain API and conform to your protocol.

    class HookEngine {
      constructor(pluginRegistry) {
        // dependency injection
        this.pluginRegistry = pluginRegistry
      }
    
      // hook
      oncreate(payload) {
        // hook call payload should identify the user
        const pluginInfo = this.pluginRegistry.query(payload.user.id)
    
        // lazy-load the plugin module when needed
        if (pluginInfo && !pluginInfo.module) {
          pluginInfo.module = require(pluginInfo.filePath)
        }
    
        // user plugin module is just normal Node.js module
        // you load it with `require`, and you call what ever function you need.
        pluginInfo.module.oncreate(payload)
      }
    }