diff --git a/.mocharc.json b/.mocharc.json index 0cc6b785..73bed22d 100644 --- a/.mocharc.json +++ b/.mocharc.json @@ -4,5 +4,5 @@ "source-map-support/register" ], "recursive": true, - "spec": ["test/setup.ts", "test/unit/common/*.test.ts", "test/unit/node/*.test.ts"] + "spec": ["test/setup.ts", "test/unit/common/**/*.test.ts", "test/unit/node/**/*.test.ts"] } diff --git a/packages/@sfx/core/CHANGELOG.md b/packages/@sfx/core/CHANGELOG.md new file mode 100644 index 00000000..6698bf34 --- /dev/null +++ b/packages/@sfx/core/CHANGELOG.md @@ -0,0 +1,11 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Added +- SFX-99: Added the `Core` module and related plugin interfaces. + - The Core module manages the registration and unregistration of plugins. + - The package also exports the `Plugin`, `PluginMetadata`, and `PluginRegistry` interfaces. diff --git a/packages/@sfx/core/README.md b/packages/@sfx/core/README.md new file mode 100644 index 00000000..20725109 --- /dev/null +++ b/packages/@sfx/core/README.md @@ -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. diff --git a/packages/@sfx/core/karma.conf.js b/packages/@sfx/core/karma.conf.js new file mode 100644 index 00000000..2afeb079 --- /dev/null +++ b/packages/@sfx/core/karma.conf.js @@ -0,0 +1 @@ +module.exports = require('../../../karma.conf.js'); diff --git a/packages/@sfx/core/package.json b/packages/@sfx/core/package.json new file mode 100644 index 00000000..0ca33d08 --- /dev/null +++ b/packages/@sfx/core/package.json @@ -0,0 +1,24 @@ +{ + "name": "@sfx/core", + "version": "0.0.0", + "description": "SF-X Core", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "esnext": "esnext/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", + "sideEffects": false +} diff --git a/packages/@sfx/core/src/core.ts b/packages/@sfx/core/src/core.ts new file mode 100644 index 00000000..49bfa96e --- /dev/null +++ b/packages/@sfx/core/src/core.ts @@ -0,0 +1,60 @@ +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 registry through Core outside of a + * plugin is discouraged. + */ + registry: PluginRegistry = Object.create(null); + + /** + * 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:** Registration is complete. All plugins have been + * initialized, and plugins may make use of other plugins. + * + * 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 register. + */ + register(plugins: Plugin[]) { + const missingDependencies = calculateMissingDependencies(plugins, this.registry); + if (missingDependencies.length) { + throw new Error('Missing dependencies: ' + missingDependencies.join(', ')); + } + + registerPlugins(plugins, this.registry); + initPlugins(plugins); + readyPlugins(plugins); + } +} diff --git a/packages/@sfx/core/src/index.ts b/packages/@sfx/core/src/index.ts new file mode 100644 index 00000000..0a5bc212 --- /dev/null +++ b/packages/@sfx/core/src/index.ts @@ -0,0 +1,2 @@ +export { default as Core } from './core'; +export { Plugin, PluginMetadata, PluginRegistry } from './plugin'; diff --git a/packages/@sfx/core/src/plugin.ts b/packages/@sfx/core/src/plugin.ts new file mode 100644 index 00000000..ff4eca4b --- /dev/null +++ b/packages/@sfx/core/src/plugin.ts @@ -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 and may safely use other plugins. + */ + 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; +} diff --git a/packages/@sfx/core/src/utils/core.ts b/packages/@sfx/core/src/utils/core.ts new file mode 100644 index 00000000..c5656576 --- /dev/null +++ b/packages/@sfx/core/src/utils/core.ts @@ -0,0 +1,74 @@ +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: PluginRegistry): string[] { + const available = [ + ...Object.keys(registry), + ...plugins.map(({ metadata: { name }}) => name), + ]; + const required = plugins.reduce((memo, plugin) => { + return [...memo, ...plugin.metadata.depends]; + }, []); + const availableSet = new Set(available); + const requiredSet = new Set(required); + const difference = new Set(Array.from(requiredSet).filter((p) => !availableSet.has(p))); + + 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); + return newlyRegistered; +} + +/** + * Calls the optional `init` function of each plugin. + * + * @param plugins The plugins to initialize. + */ +export function initPlugins(plugins: Plugin[]) { + plugins.forEach((plugin) => { + if (typeof plugin.init === 'function') { + plugin.init(); + } + }); +} + +/** + * Calls the optional `ready` function of each plugin. + * + * @param plugins The plugins to ready. + */ +export function readyPlugins(plugins: Plugin[]) { + plugins.forEach((plugin) => { + if (typeof plugin.ready === 'function') { + plugin.ready(); + } + }); +} diff --git a/packages/@sfx/core/test/setup.ts b/packages/@sfx/core/test/setup.ts new file mode 100644 index 00000000..54dac5f8 --- /dev/null +++ b/packages/@sfx/core/test/setup.ts @@ -0,0 +1 @@ +import '../../../../test/setup'; diff --git a/packages/@sfx/core/test/unit/browser/.gitignore b/packages/@sfx/core/test/unit/browser/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/packages/@sfx/core/test/unit/common/core.test.ts b/packages/@sfx/core/test/unit/common/core.test.ts new file mode 100644 index 00000000..1fbb1c97 --- /dev/null +++ b/packages/@sfx/core/test/unit/common/core.test.ts @@ -0,0 +1,83 @@ +import { expect, sinon, stub } from '../../utils'; +import Core from '../../../src/core'; +import * as CoreUtils from '../../../src/utils/core'; + +describe('Core', () => { + let core: Core; + + beforeEach(() => { + core = new Core(); + }); + + describe('constructor()', () => { + it('should create an empty null-prototype plugins registry object', () => { + expect(core.registry).to.be.empty; + expect(Object.getPrototypeOf(core.registry)).to.be.null; + }); + }); + + describe('register()', () => { + let calculateMissingDependencies; + let registerPlugins; + let initPlugins; + let readyPlugins; + + 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.registry, { m: 'mm' }); + + expect(() => core.register(plugins)).to.throw(); + expect(calculateMissingDependencies).to.be.calledWith(plugins, sinon.match(core.registry)); + }); + + it('should call the lifecycle events in order on the plugins', () => { + 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.registry)); + expect(initPlugins).to.be.calledWith(plugins); + expect(readyPlugins).to.be.calledWith(plugins); + sinon.assert.callOrder( + registerPlugins, + initPlugins, + readyPlugins + ); + }); + }); +}); diff --git a/packages/@sfx/core/test/unit/common/index.test.ts b/packages/@sfx/core/test/unit/common/index.test.ts new file mode 100644 index 00000000..3e6c923f --- /dev/null +++ b/packages/@sfx/core/test/unit/common/index.test.ts @@ -0,0 +1,27 @@ +import { AssertTypesEqual, expect } from '../../utils'; +import { + Core as CoreExport, + Plugin as PluginExport, + PluginMetadata as PluginMetadataExport, + PluginRegistry as PluginRegistryExport, +} from '../../../src'; +import Core from '../../../src/core'; +import { Plugin, PluginMetadata, PluginRegistry } from '../../../src/plugin'; + +describe('Entry point', () => { + it('should export Core', () => { + expect(CoreExport).to.equal(Core); + }); + + it('should export the Plugin interface', () => { + const test: AssertTypesEqual = true; + }); + + it('should export the PluginMetadata interface', () => { + const test: AssertTypesEqual = true; + }); + + it('should export the PluginRegistry interface', () => { + const test: AssertTypesEqual = true; + }); +}); diff --git a/packages/@sfx/core/test/unit/common/utils/core.test.ts b/packages/@sfx/core/test/unit/common/utils/core.test.ts new file mode 100644 index 00000000..11365e0d --- /dev/null +++ b/packages/@sfx/core/test/unit/common/utils/core.test.ts @@ -0,0 +1,315 @@ +import { expect, spy } from '../../../utils'; +import { + calculateMissingDependencies, + initPlugins, + readyPlugins, + registerPlugins, +} from '../../../../src/utils/core'; + +describe('CoreUtils', () => { + describe('calculateMissingDependencies()', () => { + it('should return missing dependencies', () => { + const registry = { + a: 'aa', + b: 'bb', + c: 'cc', + }; + const plugins: any = [ + { + metadata: { + name: 'm', + depends: ['a', 'x'], + }, + }, + { + metadata: { + name: 'n', + depends: ['z'], + }, + }, + ] + + const missing = calculateMissingDependencies(plugins, registry); + + expect(missing).to.have.members(['x', 'z']); + }); + + it('should return no missing dependencies when all are met', () => { + const registry = { + a: 'aa', + b: 'bb', + c: 'cc', + } + const plugins: any = [ + { + metadata: { + name: 'm', + depends: ['a', 'b'], + }, + }, + { + metadata: { + name: 'n', + depends: ['b'], + }, + }, + ] + + const missing = calculateMissingDependencies(plugins, registry); + + expect(missing).to.deep.equal([]); + }); + + it('should return no missing dependencies when there are no dependencies', () => { + const registry = { + a: 'aa', + b: 'bb', + c: 'cc', + }; + const plugins: any = [ + { + metadata: { + name: 'm', + depends: [], + }, + }, + { + metadata: { + name: 'n', + depends: [], + }, + }, + ]; + + const missing = calculateMissingDependencies(plugins, registry); + + expect(missing).to.deep.equal([]); + }); + + it('should return no missing dependencies when dependencies are satisfied by the new plugins', () => { + const registry = { + c: 'cc', + }; + const plugins: any = [ + { + metadata: { + name: 'a', + depends: ['b', 'c'], + }, + }, + { + metadata: { + name: 'b', + depends: ['c'], + }, + }, + ]; + + const missing = calculateMissingDependencies(plugins, registry); + + expect(missing).to.deep.equal([]); + }); + + it('should return no missing dependencies for satisfied circular dependencies', () => { + const registry = {}; + const plugins: any = [ + { + metadata: { + name: 'a', + depends: ['b'], + }, + }, + { + metadata: { + name: 'b', + depends: ['c'], + }, + }, + { + metadata: { + name: 'c', + depends: ['a'], + }, + }, + ]; + + const missing = calculateMissingDependencies(plugins, registry); + + expect(missing).to.deep.equal([]); + }); + }); + + describe('registerPlugins()', () => { + it('should call the register function of each plugin with the plugin registry', () => { + let localRegistryA; + let localRegistryB; + const plugins: any = [ + { + metadata: { name: 'a' }, + register: (r) => localRegistryA = r, + }, + { + metadata: { name: 'b' }, + register: (r) => localRegistryB = r, + }, + ]; + const registry: any = {}; + + registerPlugins(plugins, registry); + + expect(localRegistryA).to.be.an('object'); + expect(localRegistryB).to.be.an('object'); + + const cvalue = registry.c = 'cc'; + + expect(localRegistryA.c).to.equal(cvalue); + expect(localRegistryB.c).to.equal(cvalue); + }); + + it('should return a map of new plugin keys to exposed values', () => { + const valueA = { a: 'a' }; + const valueB = () => /b/; + const valueC = 'c'; + const plugins: any = [ + { + metadata: { name: 'pluginA' }, + register: () => valueA, + }, + { + metadata: { name: 'pluginB' }, + register: () => valueB, + }, + { + metadata: { name: 'pluginC' }, + register: () => valueC, + }, + ]; + const registry = {}; + + const newlyRegistered = registerPlugins(plugins, registry); + + expect(newlyRegistered).to.deep.equal({ + pluginA: valueA, + pluginB: valueB, + pluginC: valueC, + }); + expect(newlyRegistered.pluginA).to.equal(valueA); + expect(newlyRegistered.pluginB).to.equal(valueB); + expect(newlyRegistered.pluginC).to.equal(valueC); + }); + + it('should add the newly registered plugins to the initial registry', () => { + const valueA = { a: 'a' }; + const valueB = { b: 'b' }; + const plugins: any = [ + { + metadata: { name: 'pluginA' }, + register: () => valueA, + }, + { + metadata: { name: 'pluginB' }, + register: () => valueB, + }, + ]; + const registry: any = { a: 'b' }; + + registerPlugins(plugins, registry); + + expect(registry).to.deep.equal({ + a: 'b', + pluginA: valueA, + pluginB: valueB, + }); + expect(registry.pluginA).to.equal(valueA); + expect(registry.pluginB).to.equal(valueB); + }); + + it('should not allow plugins to modify the registry', () => { + const plugins: any = [ + { + metadata: { name: 'x' }, + register: (plugins) => { + delete plugins.a; + plugins.b = 'bb'; + }, + }, + ]; + const registry = { a: 'aa' }; + + registerPlugins(plugins, registry); + + expect(registry.a).to.equal('aa'); + expect(registry).to.not.have.any.keys('b'); + }); + }); + + describe('initPlugins()', () => { + it('should call the init function of each plugin', () => { + const initA = spy(); + const initB = spy(); + const plugins: any = [ + { + metadata: { name: 'pluginA' }, + init: initA, + }, + { + metadata: { name: 'pluginB' }, + init: initB, + }, + ]; + + initPlugins(plugins); + + expect(initA).to.be.called; + expect(initB).to.be.called; + }); + + it('should not throw when a plugin does not have an init function', () => { + const plugins: any = [ + { + metadata: { name: 'pluginA' }, + }, + { + metadata: { name: 'pluginB' }, + }, + ]; + + expect(() => initPlugins(plugins)).to.not.throw(); + }); + }); + + describe('readyPlugins()', () => { + it('should call the ready function of each plugin', () => { + const readyA = spy(); + const readyB = spy(); + const plugins: any = [ + { + metadata: { name: 'pluginA' }, + ready: readyA, + }, + { + metadata: { name: 'pluginB' }, + ready: readyB, + }, + ]; + + readyPlugins(plugins); + + expect(readyA).to.be.called; + expect(readyB).to.be.called; + }); + + it('should not throw when a plugin does not have an ready function', () => { + const plugins: any = [ + { + metadata: { name: 'pluginA' }, + }, + { + metadata: { name: 'pluginB' }, + }, + ]; + + expect(() => readyPlugins(plugins)).to.not.throw(); + }); + }); +}); diff --git a/packages/@sfx/core/test/unit/node/.gitignore b/packages/@sfx/core/test/unit/node/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/packages/@sfx/core/test/utils.ts b/packages/@sfx/core/test/utils.ts new file mode 100644 index 00000000..62901272 --- /dev/null +++ b/packages/@sfx/core/test/utils.ts @@ -0,0 +1 @@ +export * from '../../../../test/utils'; diff --git a/packages/@sfx/core/tsconfig.esnext.json b/packages/@sfx/core/tsconfig.esnext.json new file mode 100644 index 00000000..e24bec19 --- /dev/null +++ b/packages/@sfx/core/tsconfig.esnext.json @@ -0,0 +1,34 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "lib": [ + "es2015", + "es2016.array.include", + "dom" + ], + "types": [ + "node" + ], + "module": "es2015", + "moduleResolution": "node", + "target": "es2015", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "outDir": "esnext", + "sourceMap": true, + "allowSyntheticDefaultImports": true, + "noImplicitAny": true, + "declaration": true + }, + "exclude": [ + "node_modules", + "dist" + ], + "formatCodeOptions": { + "indentSize": 2, + "tabSize": 2 + }, + "include": [ + "src" + ] +} diff --git a/packages/@sfx/core/tsconfig.json b/packages/@sfx/core/tsconfig.json new file mode 100644 index 00000000..51c1f489 --- /dev/null +++ b/packages/@sfx/core/tsconfig.json @@ -0,0 +1,33 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "lib": [ + "es2015", + "es2016.array.include", + "dom" + ], + "types": [ + "node", + "mocha" + ], + "module": "commonjs", + "moduleResolution": "node", + "target": "ES5", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "outDir": "dist", + "sourceMap": true, + "declaration": true + }, + "exclude": [ + "node_modules", + "dist" + ], + "formatCodeOptions": { + "indentSize": 2, + "tabSize": 2 + }, + "include": [ + "src" + ] +} diff --git a/presets/core.ts b/presets/core.ts new file mode 100644 index 00000000..20bf499f --- /dev/null +++ b/presets/core.ts @@ -0,0 +1 @@ +import '@sfx/core'; diff --git a/test/utils.ts b/test/utils.ts index f4c062f9..a21026dc 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -8,3 +8,7 @@ export const chai = global.chai || chaiImport; export const expect = chai.expect; export const stub = sinon.stub; export const spy = sinon.spy; + +// Interface S is the same as interface T if and only if they are assignable to each other. +// This type resolves to the literal type `true` if S = T and `false` if S != T. +export type AssertTypesEqual = S extends T ? T extends S ? true : false : false; diff --git a/webpack.config.js b/webpack.config.js index 372bed56..47003d71 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -5,6 +5,7 @@ module.exports = { mode: 'development', entry: { + core: path.resolve(__dirname, 'presets/core.ts'), plugins: path.resolve(__dirname, 'presets/plugins.ts'), },