Skip to content

Commit

Permalink
chore: introduce renderer <=> plugins <=> main invocation
Browse files Browse the repository at this point in the history
  • Loading branch information
louis-menlo committed Sep 19, 2023
1 parent 22dacaa commit 0696ff1
Show file tree
Hide file tree
Showing 29 changed files with 2,568 additions and 14 deletions.
11 changes: 8 additions & 3 deletions web-client/app/_components/Preferences.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import {
plugins,
extensionPoints,
activationPoints,
} from "../../node_modules/pluggable-electron/dist/execution.es.js";
} from "../../electron/core/execution/index";
/* eslint-disable @next/next/no-sync-scripts */
export const Preferences = () => {
useEffect(() => {
async function setupPE() {
// Enable activation point management
setup({
//@ts-ignore
importer: (plugin) =>
//@ts-ignore
import(/* webpackIgnore: true */ plugin).catch((err) => {
console.log(err);
}),
Expand All @@ -23,6 +23,7 @@ export const Preferences = () => {
await plugins.registerActive();
}
setupPE();

// Install a new plugin on clicking the install button
document
?.getElementById("install-file")
Expand Down Expand Up @@ -114,7 +115,11 @@ export const Preferences = () => {
//@ts-ignore
const price = new FormData(e.target).get("price");
// Get the cost, calculated in multiple steps, by the plugins
const cost = await extensionPoints.executeSerial("calc-price", price);
const cost = await extensionPoints
.executeSerial("calc-price", price)
.catch((err) => {
console.log(err);
});
// Display result in the app
document!.getElementById("demo-cost")!.innerText = cost;
});
Expand Down
37 changes: 37 additions & 0 deletions web-client/electron/core/execution/Activation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { callExport } from "./import-manager.js"

class Activation {
/** @type {string} Name of the registered plugin. */
plugin

/** @type {string} Name of the activation point that is registered to. */
activationPoint

/** @type {string} location of the file containing the activation function. */
url

/** @type {boolean} Whether the activation has been activated. */
activated

constructor(plugin, activationPoint, url) {
this.plugin = plugin
this.activationPoint = activationPoint
this.url = url
this.activated = false
}

/**
* Trigger the activation function in the plugin once,
* providing the list of extension points or an object with the extension point's register, execute and executeSerial functions.
* @returns {boolean} Whether the activation has already been activated.
*/
async trigger() {
if (!this.activated) {
await callExport(this.url, this.activationPoint, this.plugin)
this.activated = true
}
return this.activated
}
}

export default Activation
145 changes: 145 additions & 0 deletions web-client/electron/core/execution/ExtensionPoint.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* @typedef {Object} Extension An extension registered to an extension point
* @property {string} name Unique name for the extension.
* @property {Object|Callback} response Object to be returned or function to be called by the extension point.
* @property {number} [priority] Order priority for execution used for executing in serial.
*/

/**
* Represents a point in the consumer's code that can be extended by a plugin.
* The plugin can register a callback or object to the extension point.
* When the extension point is triggered, the provided function will then be called or object will be returned.
*/
class ExtensionPoint {
/** @type {string} Name of the extension point */
name

/**
* @type {Array.<Extension>} The list of all extensions registered with this extension point.
* @private
*/
_extensions = []

/**
* @type {Array.<Object>} A list of functions to be executed when the list of extensions changes.
* @private
*/
#changeListeners = []

constructor(name) {
this.name = name
}

/**
* Register new extension with this extension point.
* The registered response will be executed (if callback) or returned (if object)
* when the extension point is executed (see below).
* @param {string} name Unique name for the extension.
* @param {Object|Callback} response Object to be returned or function to be called by the extension point.
* @param {number} [priority] Order priority for execution used for executing in serial.
* @returns {void}
*/
register(name, response, priority = 0) {
const index = this._extensions.findIndex(p => p.priority > priority)
const newExt = { name, response, priority }
if (index > -1) {
this._extensions.splice(index, 0, newExt)
} else {
this._extensions.push(newExt)
}

this.#emitChange()
}

/**
* Remove an extension from the registry. It will no longer be part of the extension point execution.
* @param {RegExp } name Matcher for the name of the extension to remove.
* @returns {void}
*/
unregister(name) {
const index = this._extensions.findIndex(ext => ext.name.match(name))
if (index > -1) this._extensions.splice(index, 1)

this.#emitChange()
}

/**
* Empty the registry of all extensions.
* @returns {void}
*/
clear() {
this._extensions = []
this.#emitChange()
}

/**
* Get a specific extension registered with the extension point
* @param {string} name Name of the extension to return
* @returns {Object|Callback|undefined} The response of the extension. If this is a function the function is returned, not its response.
*/
get(name) {
const ep = this._extensions.find(ext => ext.name === name)
return ep && ep.response
}

/**
* Execute (if callback) and return or just return (if object) the response for each extension registered to this extension point.
* Any asynchronous responses will be executed in parallel and the returned array will contain a promise for each of these responses.
* @param {*} input Input to be provided as a parameter to each response if response is a callback.
* @returns {Array} List of responses from the extensions.
*/
execute(input) {
return this._extensions.map(p => {
if (typeof p.response === 'function') {
return p.response(input)
} else {
return p.response
}
})
}

/**
* Execute (if callback) and return the response, or push it to the array if the previous response is an array
* for each extension registered to this extension point in serial,
* feeding the result from the last response as input to the next.
* @param {*} input Input to be provided as a parameter to the 1st callback
* @returns {Promise.<*>} Result of the last extension that was called
*/
async executeSerial(input) {
return await this._extensions.reduce(async (throughput, p) => {
let tp = await throughput
if (typeof p.response === 'function') {
tp = await p.response(tp)
} else if (Array.isArray(tp)) {
tp.push(p.response)
}
return tp
}, input)
}

/**
* Register a callback to be executed if the list of extensions changes.
* @param {string} name Name of the listener needed if it is to be removed.
* @param {Function} callback The callback function to trigger on a change.
*/
onRegister(name, callback) {
if (typeof callback === 'function') this.#changeListeners.push({ name, callback })
}

/**
* Unregister a callback from the extension list changes.
* @param {string} name The name of the listener to remove.
*/
offRegister(name) {
const index = this.#changeListeners.findIndex(l => l.name === name)
if (index > -1) this.#changeListeners.splice(index, 1)
}

#emitChange() {
for (const l of this.#changeListeners) {
l.callback(this)
}
}
}

export default ExtensionPoint
116 changes: 116 additions & 0 deletions web-client/electron/core/execution/ExtensionPoint.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import Ep from './ExtensionPoint'

/** @type {Ep} */
let ep
const changeListener = jest.fn()

const objectRsp = { foo: 'bar' }
const funcRsp = arr => {
arr || (arr = [])
arr.push({ foo: 'baz' })
return arr
}

beforeEach(() => {
ep = new Ep('test-ep')
ep.register('test-ext-obj', objectRsp)
ep.register('test-ext-func', funcRsp, 10)
ep.onRegister('test', changeListener)
})


it('should create a new extension point by providing a name', () => {
expect(ep.name).toEqual('test-ep')
})

it('should register extension with extension point', () => {
expect(ep._extensions).toContainEqual({
name: 'test-ext-func',
response: funcRsp,
priority: 10
})
})

it('should register extension with a default priority of 0 if not provided', () => {
expect(ep._extensions).toContainEqual({
name: 'test-ext-obj',
response: objectRsp,
priority: 0
})
})

it('should execute the change listeners on registering a new extension', () => {
changeListener.mockClear()
ep.register('test-change-listener', true)
expect(changeListener.mock.calls.length).toBeTruthy()
})

it('should unregister an extension with the provided name if it exists', () => {
ep.unregister('test-ext-obj')

expect(ep._extensions).not.toContainEqual(
expect.objectContaining({
name: 'test-ext-obj'
})
)
})

it('should not unregister any extensions if the provided name does not exist', () => {
ep.unregister('test-ext-invalid')

expect(ep._extensions.length).toBe(2)
})

it('should execute the change listeners on unregistering an extension', () => {
changeListener.mockClear()
ep.unregister('test-ext-obj')
expect(changeListener.mock.calls.length).toBeTruthy()
})

it('should empty the registry of all extensions on clearing', () => {
ep.clear()

expect(ep._extensions).toEqual([])
})

it('should execute the change listeners on clearing extensions', () => {
changeListener.mockClear()
ep.clear()
expect(changeListener.mock.calls.length).toBeTruthy()
})

it('should return the relevant extension using the get method', () => {
const ext = ep.get('test-ext-obj')

expect(ext).toEqual({ foo: 'bar' })
})

it('should return the false using the get method if the extension does not exist', () => {
const ext = ep.get('test-ext-invalid')

expect(ext).toBeUndefined()
})

it('should provide an array with all responses, including promises where necessary, using the execute method', async () => {
ep.register('test-ext-async', () => new Promise(resolve => setTimeout(resolve, 0, { foo: 'delayed' })))
const arr = ep.execute([])

const res = await Promise.all(arr)

expect(res).toContainEqual({ foo: 'bar' })
expect(res).toContainEqual([{ foo: 'baz' }])
expect(res).toContainEqual({ foo: 'delayed' })
expect(res.length).toBe(3)
})

it('should provide an array including all responses in priority order, using the executeSerial method provided with an array', async () => {
const res = await ep.executeSerial([])

expect(res).toEqual([{ "foo": "bar" }, { "foo": "baz" }])
})

it('should provide an array including the last response using the executeSerial method provided with something other than an array', async () => {
const res = await ep.executeSerial()

expect(res).toEqual([{ "foo": "baz" }])
})
35 changes: 35 additions & 0 deletions web-client/electron/core/execution/Plugin.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { callExport } from "./import-manager"

/**
* A slimmed down representation of a plugin for the renderer.
*/
class Plugin {
/** @type {string} Name of the package. */
name

/** @type {string} The electron url where this plugin is located. */
url

/** @type {Array<string>} List of activation points. */
activationPoints

/** @type {boolean} Whether this plugin should be activated when its activation points are triggered. */
active

constructor(name, url, activationPoints, active) {
this.name = name
this.url = url
this.activationPoints = activationPoints
this.active = active
}

/**
* Trigger an exported callback on the plugin's main file.
* @param {string} exp exported callback to trigger.
*/
triggerExport(exp) {
callExport(this.url, exp, this.name)
}
}

export default Plugin
Loading

0 comments on commit 0696ff1

Please sign in to comment.