-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: introduce renderer <=> plugins <=> main invocation
- Loading branch information
1 parent
22dacaa
commit 0696ff1
Showing
29 changed files
with
2,568 additions
and
14 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
116
web-client/electron/core/execution/ExtensionPoint.test.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" }]) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.