@ayanaware/bento
Version:
Modular runtime framework designed to solve complex tasks
698 lines • 25.6 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.EntityManager = exports.PluginHook = void 0;
const errors_1 = require("@ayanaware/errors");
const ChildOf_1 = require("../decorators/ChildOf");
const Inject_1 = require("../decorators/Inject");
const Parent_1 = require("../decorators/Parent");
const Subscribe_1 = require("../decorators/Subscribe");
const Variable_1 = require("../decorators/Variable");
const EntityError_1 = require("../errors/EntityError");
const EntityRegistrationError_1 = require("../errors/EntityRegistrationError");
const isClass_1 = require("../util/isClass");
const EntityEvents_1 = require("./EntityEvents");
const ReferenceManager_1 = require("./ReferenceManager");
const ComponentAPI_1 = require("./api/ComponentAPI");
const PluginAPI_1 = require("./api/PluginAPI");
const Entity_1 = require("./interfaces/Entity");
var PluginHook;
(function (PluginHook) {
PluginHook["PRE_COMPONENT_LOAD"] = "onPreComponentLoad";
PluginHook["PRE_COMPONENT_UNLOAD"] = "onPreComponentUnload";
PluginHook["POST_COMPONENT_LOAD"] = "onPostComponentLoad";
PluginHook["POST_COMPONENT_UNLOAD"] = "onPostComponentUnload";
})(PluginHook = exports.PluginHook || (exports.PluginHook = {}));
class EntityManager {
constructor(bento) {
this.events = new Map();
this.entities = new Map();
this.pending = new Map();
this.references = new ReferenceManager_1.ReferenceManager();
this.bento = bento;
}
resolveReference(reference, error) {
return this.references.resolve(reference, error);
}
/**
* Check if EntityEvents exists
* @param reference EntityReference
*
* @returns boolean
*/
hasEvents(reference) {
const name = this.references.resolve(reference);
return this.events.has(name);
}
/**
* Get EntityEvents or create it
* @param reference EntityReference
*
* @returns EntityEvents
*/
getEvents(reference) {
const name = this.references.resolve(reference, true);
if (!this.hasEvents(name)) {
const events = new EntityEvents_1.EntityEvents(name, this.bento.options);
this.events.set(name, events);
return events;
}
return this.events.get(name);
}
/**
* Get loaded entities
* @param type EntityType
*
* @returns Entity Map
*/
getEntities(type) {
const entities = Array.from(this.entities.entries())
.filter(([, entity]) => !type || entity.type === type);
return new Map(entities);
}
/**
* Check if Entity exists
* @param reference EntityReference
*
* @returns boolean
*/
hasEntity(reference) {
const name = this.references.resolve(reference);
return this.getEntities().has(name);
}
/**
* Get Entity
* @param reference EntityReference
*
* @returns Entity or null
*/
getEntity(reference) {
const name = this.references.resolve(reference);
return (this.getEntities().get(name) || null);
}
/**
* Add a Entity to Bento
* @param entity - Entity
*
* @returns Entity name
*/
async addEntity(entity) {
if (typeof entity === 'function' && (0, isClass_1.isClass)(entity))
entity = new entity();
if (entity == null || typeof entity !== 'object')
throw new errors_1.IllegalArgumentError('Entity must be a object');
if (typeof entity.name !== 'string')
throw new errors_1.IllegalArgumentError('Entity name must be a string');
if (!entity.name)
throw new EntityRegistrationError_1.EntityRegistrationError(entity, 'Entity must specify a name');
if (typeof entity.type !== 'string')
throw new errors_1.IllegalArgumentError('Entity type must be a string');
if (!entity.type)
throw new EntityRegistrationError_1.EntityRegistrationError(entity, 'Entity type must be specificed');
if (this.hasEntity(entity.name)) {
const oldEntity = this.getEntity(entity.name);
if (!oldEntity.replaceable)
throw new errors_1.IllegalArgumentError(`Entity name "${entity.name}" is already in use, and is not replaceable`);
return this.replaceEntity(oldEntity, entity);
}
this.prepareEntity(entity);
return this.loadEntity(entity);
}
/**
* Replace entity and rewrite references and name behind the scenes.
*
* This allows for the following code to work:
* ```ts
* class Old { name = 'old'; replaceable = true }
* class New { name = 'new' }
*
* await bento.replaceEntity(Old, new New());
*
* bento.getEntity(Old); // Would actually return Instance of the New class
* ```
*
* @param reference EntityReference
* @param entity Entity
*/
async replaceEntity(reference, entity) {
const oldEntity = this.getEntity(reference);
if (!oldEntity)
throw new errors_1.IllegalArgumentError('Entity to replace does not exist');
// Handle uninstantiated Entity
if (typeof entity === 'function' && (0, isClass_1.isClass)(entity))
entity = new entity();
if (entity == null || typeof entity !== 'object')
throw new errors_1.IllegalArgumentError('Entity must be a object');
if (typeof entity.name !== 'string')
throw new errors_1.IllegalArgumentError('Entity name must be a string');
if (!entity.name)
throw new EntityRegistrationError_1.EntityRegistrationError(entity, 'Entity must specify a name');
if (typeof entity.type !== 'string')
throw new errors_1.IllegalArgumentError('Entity type must be a string');
if (!entity.type)
throw new EntityRegistrationError_1.EntityRegistrationError(entity, 'Entity type must be specificed');
if (!oldEntity.replaceable)
throw new errors_1.IllegalArgumentError(`Entity name "${entity.name}" is not marked as replaceable`);
this.prepareEntity(entity);
// Remove old Entity from Bento
let removed = await this.removeEntity(oldEntity.name);
removed = removed.filter(e => e.name !== oldEntity.name);
// Continue to track constructor
// Allowing Bento to still resolve the name of old entities
this.references.add(oldEntity, entity.name);
// Rewrite if needed, for string users
if (oldEntity.name !== entity.name) {
this.references.addRewrite(oldEntity, entity.name);
}
// Load the new Entity
const name = await this.loadEntity(entity);
// Restore Dependents
for (const item of removed)
await this.addEntity(item);
return name;
}
/**
* Remove a Entity from Bento
* @param reference - Name of Entity
*/
async removeEntity(reference) {
const name = this.references.resolve(reference);
const entity = this.getEntity(name);
if (!entity)
throw new errors_1.IllegalStateError(`Entity "${name}" is not currently loaded.`);
let removed = [];
// if we have any dependents lets unload them first
const dependents = this.getEntityDependents(entity);
for (const dependent of dependents) {
// skip already removed entities
if (removed.find(d => d.name === dependent.name) != null)
continue;
const also = await this.removeEntity(dependent.name);
removed = [...removed, ...also];
}
// onPreComponentUnload
if (entity.type === Entity_1.EntityType.COMPONENT) {
await this.handlePluginHook(PluginHook.PRE_COMPONENT_UNLOAD, entity);
}
// call onUnload
if (typeof entity.onUnload === 'function') {
try {
await entity.onUnload();
}
catch {
// Ignore
}
}
removed.push(entity);
// if we were a child, inform parent of our unloading
if (entity.parent) {
entity.parent = this.references.resolve(entity.parent);
if (this.hasEntity(entity.parent)) {
const parent = this.getEntity(entity.parent);
if (typeof parent.onChildUnload === 'function') {
try {
await parent.onChildUnload(entity);
}
catch (e) {
// Ignore
}
}
}
}
// remove all event subscriptions
entity.api.unsubscribeAll();
// remove reference
this.references.remove(entity);
// delete entity
if (this.hasEntity(entity.name)) {
this.entities.delete(entity.name);
}
// onPostComponentUnload
if (entity.type === Entity_1.EntityType.COMPONENT) {
await this.handlePluginHook(PluginHook.POST_COMPONENT_UNLOAD, entity);
}
return removed;
}
/**
* Check if Plugin exists
* @param reference PluginReference
*
* @returns boolean
*/
hasPlugin(reference) {
const name = this.references.resolve(reference);
return this.getEntities(Entity_1.EntityType.PLUGIN).has(name);
}
/**
* Get all Plugins
*
* @returns Plugin Map
*/
getPlugins() {
return this.getEntities(Entity_1.EntityType.PLUGIN);
}
/**
* Get Plugin
* @param reference PluginReference
*
* @returns Plugin or null
*/
getPlugin(reference) {
const name = this.references.resolve(reference);
return (this.getEntities(Entity_1.EntityType.PLUGIN).get(name) || null);
}
/**
* Adds plugins in order of array
* @param plugins - array of plugins
*
* @returns Array of loaded plugin names
*/
async addPlugins(plugins) {
if (!Array.isArray(plugins))
throw new errors_1.IllegalArgumentError('addPlugins only accepts an array.');
const results = [];
for (const plugin of plugins) {
const name = await this.addPlugin(plugin);
results.push(name);
}
return results;
}
async addPlugin(plugin) {
if (typeof plugin === 'function' && (0, isClass_1.isClass)(plugin))
plugin = new plugin();
Object.defineProperty(plugin, 'type', {
configurable: true,
writable: false,
enumerable: true,
value: Entity_1.EntityType.PLUGIN,
});
return this.addEntity(plugin);
}
async replacePlugin(reference, plugin) {
if (typeof plugin === 'function' && (0, isClass_1.isClass)(plugin))
plugin = new plugin();
Object.defineProperty(plugin, 'type', {
configurable: true,
writable: false,
enumerable: true,
value: Entity_1.EntityType.PLUGIN,
});
return this.replaceEntity(reference, plugin);
}
async removePlugin(plugin) {
return this.removeEntity(plugin);
}
/**
* Check if Component exists
* @param reference ComponentReference
*
* @returns boolean
*/
hasComponent(reference) {
const name = this.references.resolve(reference);
return this.getEntities(Entity_1.EntityType.COMPONENT).has(name);
}
/**
* Get all Components
*
* @returns Component Map
*/
getComponents() {
return this.getEntities(Entity_1.EntityType.COMPONENT);
}
/**
* Adds components in order of array
* @param components - array of plugins
*
* @returns Array of loaded plugin names
*/
async addComponents(components) {
if (!Array.isArray(components))
throw new errors_1.IllegalArgumentError('addComponents only accepts an array');
const results = [];
for (const component of components) {
const name = await this.addComponent(component);
results.push(name);
}
return results;
}
/**
* Get Component
* @param reference ComponentReference
*
* @returns Component or null
*/
getComponent(reference) {
const name = this.references.resolve(reference);
return (this.getEntities(Entity_1.EntityType.COMPONENT).get(name) || null);
}
/**
* Add Component
* @param component Component
*
* @returns Component Name
*/
async addComponent(component) {
if (typeof component === 'function' && (0, isClass_1.isClass)(component))
component = new component();
Object.defineProperty(component, 'type', {
configurable: true,
writable: false,
enumerable: true,
value: Entity_1.EntityType.COMPONENT,
});
return this.addEntity(component);
}
/**
* Replace Component
* @param reference ComponentReference
* @param component Component
*
* @returns Component Name
*/
async replaceComponent(reference, component) {
if (typeof component === 'function' && (0, isClass_1.isClass)(component))
component = new component();
Object.defineProperty(component, 'type', {
configurable: true,
writable: false,
enumerable: true,
value: Entity_1.EntityType.COMPONENT,
});
return this.replaceEntity(reference, component);
}
/**
* Remove Component
* @param reference ComponentReference
*
* @returns Removed Entities
*/
async removeComponent(reference) {
return this.removeEntity(reference);
}
/**
* Verify loaded entites & call their onVerify() hook
* @returns Map of string/Entity pairs
*/
async verify() {
const pending = this.getPendingEntities();
if (pending.length > 0) {
throw new errors_1.IllegalStateError(`One or more entities are still in a pending state: '${pending.map(p => p.name).join('\', \'')}'`);
}
const entities = this.getEntities();
for (const [name, entity] of entities.entries()) {
if (typeof entity.onVerify !== 'function')
continue;
try {
await entity.onVerify();
}
catch (e) {
throw new EntityError_1.EntityError(`Entity "${name}".onVerify() threw error`).setCause(e);
}
}
return entities;
}
/**
* @see PendingEntityInfo
* @returns - All currently pending bento entities and their info
*/
getPendingEntities() {
const pending = [];
for (const [name, entity] of this.pending.entries()) {
// get pending items
const missing = this.getMissingDependencies(entity);
pending.push({
name,
entity,
missing,
});
}
return pending;
}
/**
* Get all dependents of a entity
* @param entity - EntityReference
*
* @returns Array of Entities
*/
getEntityDependents(entity) {
const name = this.references.resolve(entity);
if (!this.hasEntity(name))
throw new errors_1.IllegalStateError(`Entity "${name}" does not exist`);
const dependents = [];
for (const item of this.getEntities().values()) {
for (const dependency of item.dependencies) {
if (name === this.references.resolve(dependency)) {
dependents.push(item);
}
else if (item.parent && name === this.references.resolve(item.parent)) {
dependents.push(item);
}
}
}
return dependents;
}
/**
* Get missing depenencies of an Entity
* @param reference EntityReference
*
* @returns EntityReference Array
*/
getMissingDependencies(entityOrReference) {
let entity;
if (this.hasEntity(entityOrReference))
entity = this.getEntity(entityOrReference);
else
entity = entityOrReference;
// by this point we should have a entity instance, verify
if (entity == null || typeof entity !== 'object')
throw new errors_1.IllegalArgumentError('Entity must be an object');
if (entity.dependencies == null || !Array.isArray(entity.dependencies)) {
throw new errors_1.IllegalArgumentError('Entity dependencies must be an array');
}
const dependencies = [];
for (const dependency of entity.dependencies) {
const name = this.references.resolve(dependency);
// name failed to resolve or entity is not loaded
if (!name || !this.hasEntity(name))
dependencies.push(dependency);
}
return dependencies;
}
/**
* Ensures all entity dependencies resolve to a name, and no duplicates
* @param entity Entity
*/
resolveDependencies(entity) {
const dependencies = [];
for (const dependency of entity.dependencies) {
const name = this.references.resolve(dependency);
if (!name) {
dependencies.push(dependency);
continue;
}
if (name === entity.name)
continue;
else if (!dependencies.includes(name))
dependencies.push(name);
}
entity.dependencies = dependencies;
}
/**
* Handle pending entities
*
* @returns Promise
*/
async handlePendingEntities() {
let loaded = 0;
for (const entity of this.pending.values()) {
// convert depdendencies again, for any that were not loaded previously
this.resolveDependencies(entity);
const missing = this.getMissingDependencies(entity);
if (missing.length > 0)
continue;
this.pending.delete(entity.name);
try {
await this.handleLifecycle(entity);
loaded++;
}
catch (e) {
this.pending.set(entity.name, entity);
throw e;
}
}
if (loaded > 0)
return this.handlePendingEntities();
}
/**
* Prepare Decorators
* @param entity Entity
*/
prepareDecorators(entity) {
// @Inject
(0, Inject_1.getInjections)(entity.constructor).forEach(i => entity.dependencies.push(i.reference));
// @Subscribe
(0, Subscribe_1.getSubscriptions)(entity.constructor).forEach(s => entity.dependencies.push(s.reference));
// @ChildOf
const childOf = (0, ChildOf_1.getChildOf)(entity.constructor);
if (childOf !== null)
entity.parent = childOf;
// @Parent
const parent = (0, Parent_1.getParent)(entity.constructor);
if (parent !== null)
entity.parent = parent.reference;
}
/**
* Handle Decorators
* @param entity Entity
*/
handleDecorators(entity) {
// @Inject Decorator
(0, Inject_1.getInjections)(entity.constructor).forEach(i => entity.api.injectEntity(i.reference, i.key));
// @Subscribe Decorator
(0, Subscribe_1.getSubscriptions)(entity.constructor).forEach(s => entity.api.subscribe(s.reference, s.event, s.handler, entity));
// @Variable Decorator
(0, Variable_1.getVariables)(entity.constructor).forEach(v => entity.api.injectVariable(v.definition, v.key));
// @Parent Decorator
const parent = (0, Parent_1.getParent)(entity.constructor);
if (parent !== null)
entity.api.injectEntity(parent.reference, parent.propertyKey);
}
/**
* Enforces Bento API and prepares entity for loading
* @param entity - Entity
*/
prepareEntity(entity) {
// take control and redefine entity name
Object.defineProperty(entity, 'name', {
configurable: true,
writable: false,
enumerable: true,
value: entity.name,
});
// Check dependencies
if (entity.dependencies == null)
entity.dependencies = [];
if (entity.dependencies != null && !Array.isArray(entity.dependencies)) {
throw new EntityRegistrationError_1.EntityRegistrationError(entity, `${entity.name}.dependencies must be an array`);
}
if (entity.constructor) {
// if entity has constructor track it
this.references.add(entity);
// prepare decorators
this.prepareDecorators(entity);
}
// Append parent to dependencies
if (entity.parent)
entity.dependencies.push(entity.parent);
// convert dependencies
this.resolveDependencies(entity);
}
/**
* Load Entity into Bento or deffer for dependencies
* @param entity Entity
*
* @returns Entity Name
*/
async loadEntity(entity) {
// Create API instance
let api;
if (entity.type === Entity_1.EntityType.PLUGIN)
api = new PluginAPI_1.PluginAPI(this.bento, entity);
else if (entity.type === Entity_1.EntityType.COMPONENT)
api = new ComponentAPI_1.ComponentAPI(this.bento, entity);
Object.defineProperty(entity, 'api', {
configurable: true,
writable: false,
enumerable: true,
value: api,
});
// determine dependencies
const missing = this.getMissingDependencies(entity);
if (missing.length === 0) {
// All dependencies are already loaded, handle lifecycle events
await this.handleLifecycle(entity);
// if any pending entities, attempt to handle them now
if (this.pending.size > 0)
await this.handlePendingEntities();
}
else {
// not able to handle lifecycle yet :c
this.pending.set(entity.name, entity);
}
return entity.name;
}
/**
* Handle Entity Lifecycle
* @param entity Entity
*/
async handleLifecycle(entity) {
// handle parent
let parent = null;
if (entity.parent) {
entity.parent = this.references.resolve(entity.parent);
if (!this.hasEntity(entity.parent))
throw new errors_1.IllegalStateError(`Child entity "${entity.name}" loaded prior to Parent entity "${entity.parent}"`); // aka, universe bork
parent = this.getEntity(entity.parent);
}
// No class? No Decorators! hmpf!
if (entity.constructor)
this.handleDecorators(entity);
// if we are a plugin verify we are not depending on a component
if (entity.type === Entity_1.EntityType.PLUGIN) {
for (const reference of entity.dependencies) {
const dependency = this.getEntity(reference);
if (dependency.type === Entity_1.EntityType.COMPONENT)
throw new EntityRegistrationError_1.EntityRegistrationError(entity, `Cannot depend on Component "${dependency.name}"`);
}
}
// onPreComponentLoad
if (entity.type === Entity_1.EntityType.COMPONENT) {
await this.handlePluginHook(PluginHook.PRE_COMPONENT_LOAD, entity);
}
// dont move this: plugins must be defined before calling onload.
// this is because plugins can add components and other objects
// that will then need the plugin itself to continue loading
this.entities.set(entity.name, entity);
// Call onLoad if present
if (typeof entity.onLoad === 'function') {
try {
await entity.onLoad(entity.api);
}
catch (e) {
throw new EntityRegistrationError_1.EntityRegistrationError(entity, `${entity.name}.onLoad() threw error`).setCause(e);
}
}
// if we just loaded a child entity, lets inform the parent
if (parent != null && typeof parent.onChildLoad === 'function') {
try {
await parent.onChildLoad(entity);
}
catch (e) {
throw new EntityRegistrationError_1.EntityRegistrationError(parent, `Failed to load child entity "${entity.name}"`).setCause(e);
}
}
// onPostComponentLoad
if (entity.type === Entity_1.EntityType.COMPONENT) {
await this.handlePluginHook(PluginHook.POST_COMPONENT_LOAD, entity);
}
}
/**
* Call Hook for all plugins
* @param hookName Hook name
* @param component Component
*/
async handlePluginHook(hookName, component) {
for (const plugin of this.getEntities(Entity_1.EntityType.PLUGIN).values()) {
if (typeof plugin[hookName] !== 'function')
continue;
try {
await plugin[hookName](component);
}
catch (e) {
throw new errors_1.ProcessingError(`Plugin "${plugin.name}" ${hookName} hook threw an error`).setCause(e);
}
}
}
}
exports.EntityManager = EntityManager;
//# sourceMappingURL=EntityManager.js.map