-
Notifications
You must be signed in to change notification settings - Fork 1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
SFX-99: Add Core module with registration mechanism #5
Changes from 43 commits
3686851
8cc2764
ce46f63
4d9b262
ded77f8
c59df38
aa52103
623dc1c
1c2df93
21ab9ec
5835401
67ee2de
579cba5
aa3d901
e467fee
9a8bfe4
993613e
35ea4c5
c53c772
232c5d0
6a12f4e
bad8648
1a6a942
953feaa
9ca60f6
e1b1c3a
cd5ccd4
f30903d
af26a48
f21ac84
f9913c8
ba063d2
4484a0a
f9cf7a5
af6cb48
fb61a30
6a799f8
f8242d7
44d90db
bd1b758
3eafb1a
b9e6a70
ff5a280
52f48b8
1442c9a
55e8ac1
b5fe860
4b7ee8d
efad980
7bb2650
6bf7d79
af4cdef
58f2e9a
30c2f23
6baea5e
047d81b
2b04d08
702b658
9555c7e
00cb049
398430b
1b5ac59
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,27 @@ | ||
# SF-X Core | ||
|
||
This package contains the SF-X Core class and related plugin interfaces. | ||
|
||
## Usage | ||
|
||
To use Core, simply instantiate it: | ||
|
||
```js | ||
const core = new Core(); | ||
``` | ||
|
||
## Registering a plugin | ||
|
||
To register one or more plugins with Core, instantiate the plugins, then | ||
pass them to `Core.register()`: | ||
|
||
```js | ||
const core = new Core(); | ||
const pluginA = new PluginA(); | ||
const pluginB = new PluginB(); | ||
|
||
core.register([pluginA, pluginB]); | ||
``` | ||
|
||
A plugin may take configuration options in its constructor. Refer to the | ||
plugin's documentation for details. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
module.exports = require('../../../karma.conf.js'); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,21 @@ | ||
{ | ||
"name": "@sfx/core", | ||
"version": "0.0.0", | ||
"description": "SF-X Core", | ||
"main": "dist/index.js", | ||
"scripts": { | ||
"build": "../../../scripts/build.sh", | ||
"dev": "nodemon --config ../../../nodemon.json --exec npm run build", | ||
"test": "nyc mocha", | ||
"tdd": "nodemon --config ../../../nodemon.test.json --exec npm test", | ||
"test:browser": "karma start karma.conf.js", | ||
"tdd:browser": "karma start karma.conf.js --no-single-run" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "https://github.com/groupby/sfx-logic.git", | ||
"directory": "packages/@sfx/core" | ||
}, | ||
"author": "", | ||
"license": "MIT" | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,59 @@ | ||
import { Plugin, PluginRegistry } from './plugin'; | ||
import { | ||
calculateMissingDependencies, | ||
initPlugins, | ||
readyPlugins, | ||
registerPlugins, | ||
} from './utils/core'; | ||
|
||
/** | ||
* The core of the SF-X plugin system. This entity is responsible for | ||
* managing plugins and provides a mechanism for plugins to communicate | ||
* with each other. | ||
*/ | ||
export default class Core { | ||
/** | ||
* The plugin registry. This object is a dictionary containing plugin | ||
* names as keys and their corresponding exposed values as values. | ||
* | ||
* The preferred way to access plugins in the registry is through | ||
* another plugin. Accessing the plugins through Core outside of a | ||
* plugin is discouraged. | ||
*/ | ||
plugins: PluginRegistry = Object.create(null); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be called There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes it should, I think it makes it more clear. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The interface name is already |
||
|
||
/** | ||
* Register one or more plugins with Core. | ||
* | ||
* Plugins given here are registered as a batch: the next lifecycle | ||
* event does not occur until all plugins in this batch have | ||
* experienced the current lifecycle event. | ||
* | ||
* The registration lifecycle is as follows: | ||
* | ||
* 1. **Registration:** The registry is populated with each plugin's | ||
* name and the value that it wants to expose. Plugins cannot | ||
* assume that other plugins have been registered. | ||
* 2. **Initialization:** Plugins perform setup tasks. Plugins are | ||
* aware of the existence of other plugins, but should not make use | ||
* of their functionality as they may not yet be initialized. | ||
* 3. **Ready:** Plugins may make use of other plugins. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we explicitly mention that plugins are initialized in the ready phase? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Plugins are initialized in the init phase, not the ready phase. The ready phase exists as a hook to say that the registration process is done. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Rather I meant to say that we should mention that all plugins should have been initialized and their functionality is now available for use within the ready phase. |
||
* | ||
* This function ensures that all plugin dependencies are available | ||
* before proceeding to register the plugins. Circular dependencies | ||
* are supported. If dependencies are missing, an error will be | ||
* thrown. | ||
* | ||
* @param plugins An array of plugin instances to regsiter. | ||
geoffcoutts marked this conversation as resolved.
Show resolved
Hide resolved
|
||
*/ | ||
register(plugins: Plugin[]) { | ||
const missingDependencies = calculateMissingDependencies(plugins, this.plugins); | ||
if (missingDependencies.length) { | ||
throw new Error('Missing dependencies: ' + missingDependencies.join(', ')); | ||
} | ||
|
||
registerPlugins(plugins, this.plugins); | ||
initPlugins(plugins); | ||
readyPlugins(plugins); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { default as Core } from './core'; | ||
export { Plugin, PluginMetadata, PluginRegistry } from './plugin'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,83 @@ | ||
/** | ||
* A plugin for use with the SF-X Core. Plugins supply functionality to | ||
* the otherwise function-less Core. Plugins may depend on other | ||
* plugins. | ||
* | ||
* A plugin must conform to this interface to be registered with Core, | ||
* but it may define other methods and properties for internal use. | ||
*/ | ||
export interface Plugin { | ||
/** | ||
* Plugin metadata. This object provides Core with the information | ||
* necessary to register the plugin. | ||
* | ||
* Plugin authors may consider using a getter to implement this | ||
* property. | ||
*/ | ||
metadata: PluginMetadata; | ||
|
||
/** | ||
* The callback for the registration phase of the lifecycle. The | ||
* plugin will receive a reference to the plugin registry (the object | ||
* through which other plugins can be accessed) here, and the function | ||
* is expected to return a value that will be exposed through the same | ||
* registry. It is recommended to store a reference to both values in | ||
* the plugin for later retrieval. | ||
* | ||
* The plugin cannot assume that other plugins are registered in this | ||
* function. | ||
* | ||
* @param plugins The plugin registry containing all other plugins. | ||
* @returns The value to expose in the registry. | ||
*/ | ||
register: (plugins: PluginRegistry) => any; | ||
|
||
/** | ||
* The callback for the initialization phase of the lifecycle. The | ||
* plugin should do as much setup as it can in this function. | ||
* | ||
* In this function, the plugin can assume that other plugins have | ||
* been registered and are accessible through the plugin registry | ||
* object given in the `register` function, but it cannot assume that | ||
* the other plugins have been initialized. In other words, the plugin | ||
* will know which plugins are available, but it cannot use them. | ||
*/ | ||
init?: () => void; | ||
|
||
/** | ||
* The callback for the ready phase of the lifecycle. | ||
* | ||
* In this function, the plugin can assume that other plugins have | ||
* been initialized. | ||
*/ | ||
ready?: () => void; | ||
} | ||
|
||
/** | ||
* Plugin metadata. Core will use the properties specified in this | ||
* interface during the registration process. | ||
*/ | ||
export interface PluginMetadata { | ||
/** | ||
* The name of the plugin. This name will be used as the key in the | ||
* registry, so this name should be unique. By convention, this name | ||
* should be a valid JavaScript identifier and be written in | ||
* snake_case. | ||
*/ | ||
name: string; | ||
|
||
/** | ||
* The names of all the plugins that this plugin has a hard dependency | ||
* on (i.e. these plugins must be present for this plugin to | ||
* function). | ||
*/ | ||
depends: string[]; | ||
} | ||
|
||
/** | ||
* The type of the plugin registry. Each key/value pair corresponds to | ||
* the name of a plugin and its exposed value. | ||
*/ | ||
export interface PluginRegistry { | ||
[key: string]: any; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. JavaScript object keys are all strings. Do we need to specify a type here? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Unfortunately, yes. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A little explanation would be nice. Why do we need the type? Do we not get to decide what we type and what we do not? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Having the Having to specify |
||
} |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,79 @@ | ||||||
import { Plugin, PluginRegistry } from '../plugin'; | ||||||
|
||||||
/** | ||||||
* Calculates the missing dependencies of the given plugins. The given | ||||||
* plugins and the plugins in the registry are eligible to satisfy | ||||||
* dependencies. | ||||||
* | ||||||
* @param plugins The plugins whose dependencies should be checked. | ||||||
* @param registry The plugin registry containing all registered plugins. | ||||||
* @returns An array of names of missing plugins. | ||||||
*/ | ||||||
export function calculateMissingDependencies(plugins: Plugin[], registry: object): string[] { | ||||||
JoeyDeol marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
const available = [ | ||||||
...Object.keys(registry), | ||||||
...plugins.map(({ metadata: { name }}) => name), | ||||||
]; | ||||||
const availableSet = new Set(available); | ||||||
|
||||||
const required = plugins.reduce((memo, plugin) => { | ||||||
geoffcoutts marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
return [...memo, ...plugin.metadata.depends]; | ||||||
}, []); | ||||||
const requiredSet = new Set(required); | ||||||
|
||||||
const difference = new Set(Array.from(requiredSet).filter((p) => !availableSet.has(p))); | ||||||
|
||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can remove some of the empty lines above. |
||||||
return Array.from(difference.values()).sort(); | ||||||
} | ||||||
|
||||||
/** | ||||||
* Calls the `register` function of each plugin. The values returned by | ||||||
* each plugin are added to the given registry. | ||||||
* | ||||||
* @param plugins The plugins to register. | ||||||
* @param registry The registry into which to add the plugins. | ||||||
* @returns An object containing the keys and values of the new items | ||||||
* added to the registry. | ||||||
*/ | ||||||
export function registerPlugins(plugins: Plugin[], registry: PluginRegistry): PluginRegistry { | ||||||
const newlyRegistered = Object.create(null); | ||||||
|
||||||
plugins.forEach((plugin) => { | ||||||
const exposedValue = plugin.register(Object.create(registry)); | ||||||
const { name } = plugin.metadata; | ||||||
|
||||||
newlyRegistered[name] = exposedValue; | ||||||
}); | ||||||
|
||||||
Object.assign(registry, newlyRegistered); | ||||||
|
||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can probably just remove this newline. |
||||||
return newlyRegistered; | ||||||
} | ||||||
|
||||||
/** | ||||||
* Calls the `init` function of each plugin. If a plugin does not | ||||||
* exppose an `init` function, it is ignored. | ||||||
JoeyDeol marked this conversation as resolved.
Show resolved
Hide resolved
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
* | ||||||
* @param plugins The plugins to initialize. | ||||||
*/ | ||||||
export function initPlugins(plugins: Plugin[]) { | ||||||
plugins.forEach((plugin) => { | ||||||
if (typeof plugin.init === 'function') { | ||||||
plugin.init(); | ||||||
} | ||||||
}); | ||||||
} | ||||||
|
||||||
/** | ||||||
* Calls the `ready` function of each plugin. If a plugin does not | ||||||
* expose a `ready` function, it is ignored. | ||||||
JoeyDeol marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
* | ||||||
* @param plugins The plugins to ready. | ||||||
*/ | ||||||
export function readyPlugins(plugins: Plugin[]) { | ||||||
plugins.forEach((plugin) => { | ||||||
if (typeof plugin.ready === 'function') { | ||||||
plugin.ready(); | ||||||
} | ||||||
}); | ||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
import '../../../../test/setup'; |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -0,0 +1,84 @@ | ||||||
import { expect, sinon, spy, stub } from '../../utils'; | ||||||
geoffcoutts marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
import Core from '../../../src/core'; | ||||||
import * as CoreUtils from '../../../src/utils/core'; | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd love to get There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I tried using TypeScript's There are two packages that look like they may solve that issue (namely tscpaths and ts-transformer-inputs), but they don't seem to be widely used, and one of them explicitly says that it's not ready for production. The oft-recommended module-alias is not suitable for us since this is a library and not an application. Doing it through Webpack is not an option since we don't use Webpack to build the package, and we don't want a single bundle for the whole package. As much as I would like path aliasing, I don't think we can have it under our current circumstances unless we decide that the risk of using either tscpaths or ts-transformer-inputs is worth it. |
||||||
|
||||||
describe('Core', () => { | ||||||
let core: Core; | ||||||
|
||||||
beforeEach(() => { | ||||||
core = new Core(); | ||||||
}); | ||||||
|
||||||
describe('constructor()', () => { | ||||||
it('should create an empty null-prototype plugins registry object', () => { | ||||||
expect(core.plugins).to.be.empty; | ||||||
expect(Object.getPrototypeOf(core.plugins)).to.be.null; | ||||||
}); | ||||||
}); | ||||||
|
||||||
describe('register()', () => { | ||||||
let calculateMissingDependencies = stub(CoreUtils, 'calculateMissingDependencies').returns(['x']); | ||||||
let registerPlugins = stub(CoreUtils, 'registerPlugins'); | ||||||
let initPlugins = stub(CoreUtils, 'initPlugins'); | ||||||
let readyPlugins = stub(CoreUtils, 'readyPlugins'); | ||||||
geoffcoutts marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
|
||||||
beforeEach(() => { | ||||||
calculateMissingDependencies = stub(CoreUtils, 'calculateMissingDependencies'); | ||||||
registerPlugins = stub(CoreUtils, 'registerPlugins'); | ||||||
initPlugins = stub(CoreUtils, 'initPlugins'); | ||||||
readyPlugins = stub(CoreUtils, 'readyPlugins'); | ||||||
}); | ||||||
|
||||||
it('should throw if dependencies are not met', () => { | ||||||
const plugins: any = [ | ||||||
{ | ||||||
metadata: { | ||||||
name: 'a', | ||||||
depends: ['x'], | ||||||
}, | ||||||
}, | ||||||
{ | ||||||
metadata: { | ||||||
name: 'b', | ||||||
depends: ['a'], | ||||||
}, | ||||||
}, | ||||||
]; | ||||||
calculateMissingDependencies.returns(['x']); | ||||||
Object.assign(core.plugins, { m: 'mm' }); | ||||||
|
||||||
expect(() => core.register(plugins)).to.throw(); | ||||||
expect(calculateMissingDependencies).to.be.calledWith(plugins, sinon.match(core.plugins)); | ||||||
}); | ||||||
|
||||||
it('should call the lifecycle events on the plugins in order', () => { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. To be pedantic about the language clarity:
Suggested change
|
||||||
const plugins: any = [ | ||||||
{ | ||||||
metadata: { | ||||||
name: 'a', | ||||||
depends: [], | ||||||
}, | ||||||
}, | ||||||
{ | ||||||
metadata: { | ||||||
name: 'b', | ||||||
depends: [], | ||||||
}, | ||||||
}, | ||||||
]; | ||||||
calculateMissingDependencies.returns([]); | ||||||
|
||||||
core.register(plugins); | ||||||
|
||||||
expect(registerPlugins).to.be.calledWith(plugins, sinon.match(core.plugins)); | ||||||
expect(registerPlugins).to.be.calledWith(plugins, sinon.match(core.plugins)); | ||||||
JoeyDeol marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||
expect(initPlugins).to.be.calledWith(plugins); | ||||||
expect(readyPlugins).to.be.calledWith(plugins); | ||||||
sinon.assert.callOrder( | ||||||
registerPlugins, | ||||||
initPlugins, | ||||||
readyPlugins | ||||||
); | ||||||
}); | ||||||
}); | ||||||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we just take an arbitrary number of plugins as arguments instead of a single array? This need not be done right away, but the interface would feel simpler.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Accepting an array allows us to accept registration options that apply to the batch in the future. Looking ahead to a possible "provides" implementation, one such option may be to specify which plugin, out of multiple plugins that provide the same name, should be exposed under that name.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can probably still accept options if we do some magic in the
register()
method. The plugins have to be actual plugins that conform to a specific interface, and the final argument could be an optional plain old object.It's possible that this is considered not-cool in JavaScript, but it would still be an easier interface for our clients.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I realized that if we do that, we would have no way to differentiate between a plugin and the options, especially if the options are optional (which it should be). In addition, I think the common case will be to register a number of plugins at the start, so an array is reasonable. I think I'll keep the array for those reasons.