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:
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.
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".
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.
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)
}
}