diff --git a/src/addons.js b/src/addons.js index d47a120e..bd66b8fa 100644 --- a/src/addons.js +++ b/src/addons.js @@ -284,7 +284,7 @@ export default class AddonManager extends Module { })); // Error if this takes more than 5 seconds. - await timeout(this.waitFor(`addon.${id}:registered`), 5000); + await timeout(this.waitFor(`addon.${id}:instanced`), 5000); module = this.resolve(`addon.${id}`); if ( module && ! module.loaded ) diff --git a/src/bridge.js b/src/bridge.js index 3e6e036d..f702f0cc 100644 --- a/src/bridge.js +++ b/src/bridge.js @@ -11,6 +11,8 @@ import {serializeBlob, deserializeBlob} from 'utilities/blobs'; import SettingsManager from './settings/index'; class FFZBridge extends Module { + static construct_requires = null; + constructor() { super(); const start_time = performance.now(), diff --git a/src/experiments.js b/src/experiments.js index fcd3b5c3..f9bb90e3 100644 --- a/src/experiments.js +++ b/src/experiments.js @@ -45,11 +45,11 @@ function sortExperimentLog(a,b) { // ============================================================================ export default class ExperimentManager extends Module { + static construct_requires = ['settings']; + constructor(...args) { super(...args); - this.inject('settings'); - this.settings.addUI('experiments', { path: 'Debugging > Experiments', component: 'experiments', @@ -289,7 +289,7 @@ export default class ExperimentManager extends Module { return window.__twilightSettings.experiments; const core = this.resolve('site')?.getCore?.(); - return core && core.experiments.experiments; + return core && core.experiments?.experiments; } diff --git a/src/i18n.js b/src/i18n.js index cf3af7ff..847205ab 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -69,6 +69,8 @@ const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'Uw // ============================================================================ export class TranslationManager extends Module { + static construct_requires = null; + constructor(...args) { super(...args); this.inject('settings'); @@ -90,7 +92,71 @@ export class TranslationManager extends Module { this.changed_strings = 0; this.capturing = false; this.captured = new Map; + } + getLocaleOptions(val) { + if( val === undefined ) + val = this.settings.get('i18n.locale'); + + const normal_out = [], + joke_out = []; + + for(const locale of this.availableLocales) { + const data = this.localeData[locale]; + let title = data?.native_name || data?.name || locale; + + if ( data?.coverage != null && data?.coverage < 100 ) + title = this.t('i18n.locale-coverage', '{name} ({coverage,number,percent} Complete)', { + name: title, + coverage: data.coverage / 100 + }); + + const entry = { + selected: val === locale, + value: locale, + title + }; + + if ( data?.joke ) + joke_out.push(entry); + else + normal_out.push(entry); + } + + normal_out.sort((a, b) => a.title.localeCompare(b.title)); + joke_out.sort((a, b) => a.title.localeCompare(b.title)); + + let out = [{ + selected: val === -1, + value: -1, + i18n_key: 'setting.appearance.localization.general.language.twitch', + title: "Use Twitch's Language" + }]; + + if ( normal_out.length ) { + out.push({ + separator: true, + i18n_key: 'setting.appearance.localization.general.language.languages', + title: 'Supported Languages' + }); + + out = out.concat(normal_out); + } + + if ( joke_out.length ) { + out.push({ + separator: true, + i18n_key: 'setting.appearance.localization.general.language.joke', + title: 'Joke Languages' + }); + + out = out.concat(joke_out); + } + + return out; + } + + onEnable() { this.settings.addUI('i18n.debug.open', { path: 'Debugging > Localization >> Editing', component: 'i18n-open', @@ -264,71 +330,7 @@ export class TranslationManager extends Module { this.emit(':update') } }); - } - getLocaleOptions(val) { - if( val === undefined ) - val = this.settings.get('i18n.locale'); - - const normal_out = [], - joke_out = []; - - for(const locale of this.availableLocales) { - const data = this.localeData[locale]; - let title = data?.native_name || data?.name || locale; - - if ( data?.coverage != null && data?.coverage < 100 ) - title = this.t('i18n.locale-coverage', '{name} ({coverage,number,percent} Complete)', { - name: title, - coverage: data.coverage / 100 - }); - - const entry = { - selected: val === locale, - value: locale, - title - }; - - if ( data?.joke ) - joke_out.push(entry); - else - normal_out.push(entry); - } - - normal_out.sort((a, b) => a.title.localeCompare(b.title)); - joke_out.sort((a, b) => a.title.localeCompare(b.title)); - - let out = [{ - selected: val === -1, - value: -1, - i18n_key: 'setting.appearance.localization.general.language.twitch', - title: "Use Twitch's Language" - }]; - - if ( normal_out.length ) { - out.push({ - separator: true, - i18n_key: 'setting.appearance.localization.general.language.languages', - title: 'Supported Languages' - }); - - out = out.concat(normal_out); - } - - if ( joke_out.length ) { - out.push({ - separator: true, - i18n_key: 'setting.appearance.localization.general.language.joke', - title: 'Joke Languages' - }); - - out = out.concat(joke_out); - } - - return out; - } - - onEnable() { this.capturing = this.settings.get('i18n.debug.capture'); if ( this.capturing ) this.loadStrings(); diff --git a/src/player.js b/src/player.js index e2471559..f3ecab74 100644 --- a/src/player.js +++ b/src/player.js @@ -15,6 +15,8 @@ import {TranslationManager} from './i18n'; import Site from './sites/player'; class FFZPlayer extends Module { + static construct_requires = null; + constructor() { super(); const start_time = performance.now(), diff --git a/src/raven.js b/src/raven.js index 1607640b..201bb371 100644 --- a/src/raven.js +++ b/src/raven.js @@ -54,6 +54,8 @@ const ERROR_STRINGS = [ // ============================================================================ export default class RavenLogger extends Module { + static construct_requires = null; + constructor(...args) { super(...args); @@ -62,7 +64,9 @@ export default class RavenLogger extends Module { // Do these in an event handler because we're initialized before // settings are even ready. this.once('settings:enabled', () => { - this.settings.add('reports.error.enable', { + const settings = this.resolve('settings'); + + settings.add('reports.error.enable', { default: true, ui: { path: 'Data Management > Reporting >> Error Reports', @@ -71,7 +75,7 @@ export default class RavenLogger extends Module { } }); - this.settings.add('reports.error.include-user', { + settings.add('reports.error.include-user', { default: false, ui: { path: 'Data Management > Reporting >> Error Reports', @@ -81,7 +85,7 @@ export default class RavenLogger extends Module { } }); - this.settings.add('reports.error.include-settings', { + settings.add('reports.error.include-settings', { default: true, ui: { path: 'Data Management > Reporting >> Error Reports', @@ -91,7 +95,7 @@ export default class RavenLogger extends Module { } }); - this.settings.addUI('reports.error.example', { + settings.addUI('reports.error.example', { path: 'Data Management > Reporting >> Example Report', component: 'async-text', diff --git a/src/settings/index.js b/src/settings/index.js index 911e009b..0e674150 100644 --- a/src/settings/index.js +++ b/src/settings/index.js @@ -40,6 +40,8 @@ export const NO_SYNC_KEYS = ['session']; * @extends Module */ export default class SettingsManager extends Module { + static construct_requires = null; + /** * Create a SettingsManager module. */ diff --git a/src/sites/player/index.jsx b/src/sites/player/index.jsx index 51926630..d1486f8d 100644 --- a/src/sites/player/index.jsx +++ b/src/sites/player/index.jsx @@ -23,12 +23,15 @@ export default class PlayerSite extends BaseSite { constructor(...args) { super(...args); + this.inject('settings'); this.inject('i18n'); this.inject(Fine); this.inject(Player); this.inject('tooltips', Tooltips); this.inject('css_tweaks', CSSTweaks); + } + onEnable() { this.DataSource = this.fine.define( 'data-source', n => n.consentMetadata && n.onPlaying && n.props && n.props.data @@ -38,10 +41,6 @@ export default class PlayerSite extends BaseSite { 'player-menu', n => n.closeSettingsMenu && n.state && n.state.activeMenu && n.getMaxMenuHeight ); - } - - onEnable() { - this.settings = this.resolve('settings'); this.DataSource.on('mount', this.updateData, this); this.DataSource.on('update', this.updateData, this); diff --git a/src/sites/player/metadata.jsx b/src/sites/player/metadata.jsx index fc0b465e..e3b08e47 100644 --- a/src/sites/player/metadata.jsx +++ b/src/sites/player/metadata.jsx @@ -22,20 +22,6 @@ export default class Metadata extends Module { this.definitions = {}; - this.settings.add('metadata.player-stats', { - default: false, - changed: () => this.updateMetadata('player-stats') - }); - - this.settings.add('metadata.stream-delay-warning', { - default: 0 - }); - - this.settings.add('metadata.uptime', { - default: 1, - changed: () => this.updateMetadata('uptime') - }); - this.definitions.uptime = { inherit: true, no_arrow: true, @@ -353,6 +339,20 @@ export default class Metadata extends Module { } onEnable() { + this.settings.add('metadata.player-stats', { + default: false, + changed: () => this.updateMetadata('player-stats') + }); + + this.settings.add('metadata.stream-delay-warning', { + default: 0 + }); + + this.settings.add('metadata.uptime', { + default: 1, + changed: () => this.updateMetadata('uptime') + }); + const md = this.tooltips.types.metadata = target => { let el = target; if ( el._ffz_stat ) diff --git a/src/sites/player/player.jsx b/src/sites/player/player.jsx index b101ec7f..4f107fec 100644 --- a/src/sites/player/player.jsx +++ b/src/sites/player/player.jsx @@ -23,7 +23,6 @@ export default class Player extends PlayerBase { 'player-source', n => n.setSrc && n.setInitialPlaybackSettings ); - } wantsMetadata() { return this.settings.get('player.embed-metadata'); diff --git a/src/sites/twitch-twilight/modules/video_chat/index.jsx b/src/sites/twitch-twilight/modules/video_chat/index.jsx index a26e9ea5..2164f33d 100644 --- a/src/sites/twitch-twilight/modules/video_chat/index.jsx +++ b/src/sites/twitch-twilight/modules/video_chat/index.jsx @@ -27,7 +27,7 @@ export default class VideoChatHook extends Module { this.inject('site.web_munch'); this.inject('chat'); - this.injectAs('site_chat', 'site.chat'); + this.inject('site.chat', undefined, false, 'site_chat'); this.inject('site.chat.chat_line.rich_content'); this.VideoChatController = this.fine.define( diff --git a/src/utilities/module.js b/src/utilities/module.js index 077e9215..a0f7a556 100644 --- a/src/utilities/module.js +++ b/src/utilities/module.js @@ -7,6 +7,7 @@ import EventEmitter from 'utilities/events'; import {has} from 'utilities/object'; +import { load } from './font-awesome'; // ============================================================================ @@ -22,8 +23,12 @@ export const State = { DISABLED: 0, ENABLING: 1, ENABLED: 2, - DISABLING: 3 -} + DISABLING: 3, + + UNINJECTED: 0, + LOAD_INJECTED: 1, + FULL_INJECTED: 2 +}; export class Module extends EventEmitter { @@ -34,6 +39,9 @@ export class Module extends EventEmitter { } super(name, parent); + this.__module_promises = parent ? parent.__module_promises : {}; + this.__module_dependents = parent ? parent.__module_dependents : {}; + this.__module_sources = parent ? parent.__module_sources : {}; this.__modules = parent ? parent.__modules : {}; this.children = {}; @@ -43,12 +51,22 @@ export class Module extends EventEmitter { if ( this.root === this ) this.__modules[this.__path || ''] = this; + this.__constructed = false; + this.__load_injections = {}; + this.__enable_injections = {}; + this.__inject_state = State.UNINJECTED; this.__load_state = this.onLoad ? State.UNLOADED : State.LOADED; this.__state = this.onLoad || this.onEnable ? State.DISABLED : State.ENABLED; + // Inject any pre-construction injections. + const injections = this.__get_construct_requires(); + if ( injections ) + for(const [key, path] of Object.entries(injections)) + this[key] = this.resolve(path, false, false); + this.__time('instance'); - this.emit(':registered'); + this.emit(':instanced'); } @@ -65,7 +83,6 @@ export class Module extends EventEmitter { get enabled() { return this.__state === State.ENABLED } get enabling() { return this.__state === State.ENABLING } - get log() { if ( ! this.__log ) this.__log = this.parent && this.parent.log.get(this.name); @@ -112,6 +129,17 @@ export class Module extends EventEmitter { } + __inject(injections) { + for(const [attr, name] of Object.entries(injections)) { + const module = this.resolve(name); + if ( ! module || !(module instanceof Module) ) + throw new ModuleError(`unable to inject dependency ${name} for module ${this.name}`); + + this[attr] = module; + } + } + + __load(args, initial, chain) { const path = this.__path || this.name, state = this.__load_state; @@ -142,16 +170,27 @@ export class Module extends EventEmitter { if ( this.load_requires ) { const promises = []; for(const name of this.load_requires) { - const module = this.resolve(name); - if ( ! module || !(module instanceof Module) ) - throw new ModuleError(`cannot find required module ${name} when loading ${path}`); + // Resolve and instantiate the module. + promises.push(Promise.resolve(this.resolve(name, true)).then(module => { + if ( ! module || !(module instanceof Module) ) + throw new ModuleError(`cannot find required module ${name} when loading ${path}`); - promises.push(module.__enable([], initial, Array.from(chain))); + return module.__enable([], initial, Array.from(chain)); + })); } await Promise.all(promises); } + if ( this.__inject_state === State.UNINJECTED ) { + if ( this.__load_injections ) { + this.__inject(this.__load_injections); + this.__load_injections = null; + } + + this.__inject_state = State.LOAD_INJECTED; + } + if ( this.onLoad ) { this.__time('load-self'); return this.onLoad(...args); @@ -163,6 +202,7 @@ export class Module extends EventEmitter { this.__time('load-end'); this.emit(':loaded', this); return ret; + }).catch(err => { this.__load_state = State.UNLOADED; this.__load_promise = null; @@ -208,6 +248,8 @@ export class Module extends EventEmitter { if ( this.load_dependents ) { const promises = []; for(const name of this.load_dependents) { + // All our dependents should be instantiated. An uninstantiated module is not loaded + // so we obviously do not need to unload it at this time. const module = this.resolve(name); if ( ! module ) //throw new ModuleError(`cannot find depending module ${name} when unloading ${path}`); @@ -228,6 +270,7 @@ export class Module extends EventEmitter { this.__time('unload-end'); this.emit(':unloaded', this); return ret; + }).catch(err => { this.__load_state = State.LOADED; this.__load_promise = null; @@ -237,6 +280,44 @@ export class Module extends EventEmitter { } + /*generateLoadGraph(chain) { + let initial = false; + if ( ! chain ) { + chain = []; + initial = true; + } + + if ( chain.includes(this) ) + return [`${this.name}: cyclic requirement`]; + + chain.push(this); + + const out = []; + out.push(`${this.name}: ${this.enabled ? 'enabled' : this.enabling ? 'enabling' : this.disabling ? 'disabling' : 'disabled'}`); + + const requires = this.requires; + if ( requires ) + for(const req of requires) { + const module = this.resolve(req) + let mod_out; + if ( ! module ) + mod_out = [`${req}: uninstantiated`]; + else if ( ! module.enabled ) + mod_out = module.generateLoadGraph(Array.from(chain)); + else + continue; + + for(const line of mod_out) + out.push(` ${line}`); + } + + if ( initial ) + return out.join('\n'); + + return out; + }*/ + + __enable(args, initial, chain) { const path = this.__path || this.name, state = this.__state; @@ -268,24 +349,42 @@ export class Module extends EventEmitter { requires = this.requires, load_state = this.__load_state; + // Make sure our module is loaded before enabling it. if ( load_state === State.UNLOADING ) - // We'd abort for this later to, but kill it now before we start + // We'd abort for this later too, but kill it now before we start // any unnecessary work. throw new ModuleError(`attempted to load module ${path} while module is being unloaded`); else if ( load_state === State.LOADING || load_state === State.UNLOADED ) promises.push(this.load()); + // We also want to load all our dependencies. if ( requires ) for(const name of requires) { - const module = this.resolve(name); - if ( ! module || !(module instanceof Module) ) - throw new ModuleError(`cannot find required module ${name} when enabling ${path}`); + promises.push(Promise.resolve(this.resolve(name, true).then(module => { + if ( ! module || !(module instanceof Module) ) + throw new ModuleError(`cannot find required module ${name} when enabling ${path}`); - promises.push(module.__enable([], initial, Array.from(chain))); + return module.__enable([], initial, Array.from(chain)); + }))); } await Promise.all(promises); + + if ( this.__inject_state !== State.FULL_INJECTED ) { + if ( this.__load_injections ) { + this.__inject(this.__load_injections); + this.__load_injections = null; + } + + if ( this.__enable_injections ) { + this.__inject(this.__enable_injections); + this.__enable_injections = null; + } + + this.__inject_state = State.FULL_INJECTED; + } + if ( this.onEnable ) { this.__time('enable-self'); return this.onEnable(...args); @@ -336,15 +435,18 @@ export class Module extends EventEmitter { this.__time('disable-start'); this.__state = State.DISABLING; + return this.__state_promise = (async () => { if ( this.__load_state !== State.LOADED ) - // We'd abort for this later to, but kill it now before we start + // We'd abort for this later too, but kill it now before we start // any unnecessary work. throw new ModuleError(`attempted to disable module ${path} but module is unloaded -- weird state`); if ( this.dependents ) { const promises = []; for(const name of this.dependents) { + // All our dependents should be instantiated. An uninstantiated module is not enabled + // so we obviously do not need to disable it at this time. const module = this.resolve(name); if ( ! module ) // Assume a non-existent module isn't enabled. @@ -413,38 +515,210 @@ export class Module extends EventEmitter { // Child Control // ======================================================================== - loadModules(...names) { - return Promise.all(names.map(n => this.resolve(n).load())) + // These aren't being used anywhere. + /*loadModules(...names) { + return Promise.all(names.map(n => this.resolve(n, true).then(module => module.load()))); } unloadModules(...names) { - return Promise.all(names.map(n => this.resolve(n).unload())) + return Promise.all(names.map(n => this.resolve(n)?.unload?.())); } enableModules(...names) { - return Promise.all(names.map(n => this.resolve(n).enable())) + return Promise.all(names.map(n => this.resolve(n, true).then(module => module.enable()))); } disableModules(...names) { - return Promise.all(names.map(n => this.resolve(n).disable())) - } + return Promise.all(names.map(n => this.resolve(n)?.disable?.())); + }*/ // ======================================================================== // Module Management // ======================================================================== - resolve(name) { - if ( name instanceof Module ) - return name; + /** + * Resolve a module. This will only return a module that has already been + * constructed, by default. If `construct` is true, a Promise will be + * returned and a module instance will be constructed. + * + * @param {String} name The name of the module to resolve. + * @param {Boolean} [construct=false] Whether or not a module + * should be constructed if it has not been already. When this is true, + * this method will always return a promise. When this is false, the + * method will never return a promise. + * @param {Boolean} [allow_missing=true] When this is false, an exception + * will be thrown for a missing module, rather than returning null. + * @returns {Module|Promise} A module, or a Promise that will return a + * module, depending on the value of `construct`. + */ + resolve(name, construct = false, allow_missing = true) { + const path = this.abs_path(name), + source = this.__module_sources[path], + module = this.__modules[path]; - return this.__modules[this.abs_path(name)]; + if ( ! construct ) { + if ( ! module && ! allow_missing ) { + if ( source ) + throw new ModuleError(`instance for module "${path}" has not been constructed`); + else + throw new ModuleError(`unknown module "${path}"`); + } + + return module || null; + } + + // We have the module already, but wrap it in a promise for safety. + if ( module ) + return Promise.resolve(module); + + // We do not have the module. Do we know how to load it? + // If not, then return null or an exception. + if ( ! source ) { + if ( allow_missing ) + return Promise.resolve(null); + + return Promise.reject(new ModuleError(`unknown module "${path}"`)); + } + + // To instantiate a module, we need the name and the parent module. + const idx = path.lastIndexOf('.'), + nm = path.slice(idx + 1); + let p_path = null; + if ( idx !== -1 ) + p_path = path.slice(0, idx); + + console.log('resolve', name, path, nm, p_path); + + // Is there an existing promise for constructing this module? + if ( this.__module_promises[path] ) + return new Promise((s,f) => this.__module_promises[path].push([s,f])); + + // We're still here, so load and instantiate the module, then + // return it. + return new Promise((s,f) => { + const proms = this.__module_promises[path] = [[s,f]]; + + (async () => { + let parent; + if ( p_path === this.__path ) + parent = this; + else if ( p_path ) + parent = await this.resolve(p_path, true, false); + else + parent = this.root; + + const loader = await source; + if ( ! loader ) + throw new ModuleError(`invalid loader for module "${path}`); + + // Do we have pre-construct requirements? + const pre_requires = name === 'settings' ? null : ['settings']; + + if ( Array.isArray(pre_requires) ) { + const promises = []; + for(const dep of pre_requires) + promises.push(Promise.resolve(this.resolve(dep, true)).then(module => { + if ( ! module || !(module instanceof Module) ) + throw new ModuleError(`cannot find required module ${dep} when loading ${path}`); + + return module.__enable([], path, []); + })); + + await Promise.all(promises); + } + + let module; + if ( loader.prototype instanceof Module ) + module = new loader(nm, parent); + else + module = loader(nm, parent); + + if ( ! module || !(module instanceof Module)) + throw new ModuleError(`invalid return value from module constructor for module "${path}"`); + + module.__constructed = true; + this.__modules[path] = module; + + const deps = this.__module_dependents[path]; + this.__module_dependents[path] = null; + + // Copy over the dependencies. + if ( deps ) { + if ( deps.load ) + module.load_dependents = module.load_dependents ? [...module.load_dependents, ...deps.load] : deps.load; + if ( deps.enable ) + module.dependents = module.dependents ? [...module.dependents, ...deps.enable] : deps.enable; + } + + // Inject any requirements. + this.__reflectDependencies(); + return module; + + })().then(result => { + this.__module_promises[path] = null; + for(const pair of proms) + pair[0](result); + }).catch(err => { + this.__module_promises[path] = null; + for(const pair of proms) + pair[1](err); + }); + }); } - hasModule(name) { - const module = this.__modules[this.abs_path(name)]; - return module instanceof Module; + /** + * Reflect the dependencies of this module, registering it with + * all modules it depends on as a dependent so that those modules + * know to disable or unload this module when appropriate. + * + * @param {String[]} [load=null] An optional array of specific load dependencies to reflect. + * @param {String[]} [enable=null] An optional array of specific dependencies to reflect. + * @returns {undefined} Nothing + */ + __reflectDependencies(load = null, enable = null) { + if ( load == null ) + load = this.__get_load_requires(); + if ( enable == null ) + enable = this.__get_requires(); + + if ( load && ! Array.isArray(load) ) + load = [load]; + if ( enable && ! Array.isArray(enable) ) + enable = [enable]; + + const local = this.__path || 'core'; + + if ( load && load.length ) + for(const path of load) { + const module = this.__modules[path]; + if ( module ) { + const dependents = module.load_dependents = module.load_dependents || []; + if ( ! dependents.includes(local) ) + dependents.push(local); + } else { + const dependents = this.__module_dependents[path] = this.__module_dependents[path] || {}, + set = dependents.load = dependents.load || []; + if ( ! set.includes(local) ) + set.push(local); + } + } + + if ( enable && enable.length ) + for(const path of enable) { + const module = this.__modules[path]; + if ( module ) { + const dependents = module.dependents = module.dependents || []; + if ( ! dependents.includes(local) ) + dependents.push(local); + } else { + const dependents = this.__module_dependents[path] = this.__module_dependents[path] || {}, + set = dependents.enable = dependents.enable || []; + if ( ! set.includes(local) ) + set.push(local); + } + } } @@ -464,182 +738,146 @@ export class Module extends EventEmitter { } - inject(name, module, require = true) { - if ( name instanceof Module || name.prototype instanceof Module ) { - require = module != null ? module : true; - module = name; - name = null; + __get_construct_requires() { + let out; + if ( has(this, 'construct_requires') ) + out = this.construct_requires; + else if ( has(this.constructor, 'construct_requires') ) + out = this.constructor.construct_requires; + else + out = ['settings', 'i18n', 'experiments']; + + if ( ! out || ! Array.isArray(out) ) + return out; + + const obj = {}; + for(const path of out) { + const full = this.abs_path(path), + idx = full.lastIndexOf('.'), + name = full.slice(idx + 1); + + obj[name] = full; } - const requires = this.requires = this.__get_requires() || []; - - if ( module instanceof Module ) { - // Existing Instance - if ( ! name ) - name = module.constructor.name.toSnakeCase(); - - } else if ( module && module.prototype instanceof Module ) { - // New Instance - if ( ! name ) - name = module.name.toSnakeCase(); - - module = this.register(name, module); - - } else if ( name ) { - // Just a Name - const full_name = name; - name = name.replace(/^(?:[^.]*\.)+/, ''); - module = this.resolve(full_name); - - // Allow injecting a module that doesn't exist yet? - - if ( ! module || !(module instanceof Module) ) { - if ( module ) - module[2].push([this.__path, name]); - else - this.__modules[this.abs_path(full_name)] = [[], [], [[this.__path, name]]] - - requires.push(this.abs_path(full_name)); - - return this[name] = null; - } - - } else - throw new TypeError(`must provide a valid module name or class`); - - if ( ! module ) - throw new Error(`cannot find module ${name} or no module provided`); - - if ( require ) - requires.push(module.abs_path('.')); - - if ( this.enabled && ! module.enabled ) - module.enable(); - - return this[name] = module; + return obj; } - injectAs(variable, name, module, require = true) { - if ( name instanceof Module || name.prototype instanceof Module ) { - require = module != null ? module : true; - module = name; - name = null; + /** + * Inject a dependency into this module. Dependencies are added as + * requirements, and are saved as variables with the module's name + * within this module for easy access. Injecting the module `settings` + * for example allows access to `this.settings` to use the settings + * module. + * + * Please note that injected dependencies are NOT available until + * the module is being enabled in the case of normal dependencies, + * or until the module is being loaded in the case of load + * dependencies. + * + * **Note:** Rather than providing a name, you can provide only + * a Module class or instance and the name will be determined + * based on the class name of the module. When doing so, you are + * not allowed to provide a Promise or other type of function + * loader. + * + * @param {String} name The name of the module to inject. + * @param {Class|Function|Promise} [loader] The loader that will + * provide the module we're injecting. This will be used to + * construct the module on demand . + * @param {Boolean} [load=false] If this is true, the injected + * dependency will be treated as a load dependency rather and + * injected prior to this module being loaded. + * @param {String} [key=null] An optional attribute name for + * injecting this dependency. If not provided, the name will be + * used as the attribute name. + * @returns {undefined} Nothing + */ + inject(name, loader, load = false, key = null) { + if ( this.__constructed ) + throw new ModuleError(`Unable to use inject() outside constructor`); + + // Did we get a name? + if ( typeof name !== 'string' ) { + // We didn't. Did we get a Module? + if ( name instanceof Module || name.prototype instanceof Module ) { + key = load; + load = loader; + loader = name; + name = null; + } else + throw new Error(`invalid type for name`); } - const requires = this.requires = this.__get_requires() || []; + // If we have a loader, go ahead and register it. This will also give us + // a name if we don't have one. + if ( loader ) + name = this.register(name, loader); - if ( module instanceof Module ) { - // Existing Instance - if ( ! name ) - name = module.constructor.name.toSnakeCase(); + if ( ! key ) { + const idx = name.lastIndexOf('.'); + key = (idx === -1 ? name : name.slice(idx + 1)).toSnakeCase(); + } - } else if ( module && module.prototype instanceof Module ) { - // New Instance - if ( ! name ) - name = module.name.toSnakeCase(); + const path = this.abs_path(name); - module = this.register(name, module); + // Save this dependency, and also save it on the target module. + if ( load ) { + const requires = this.load_requires = this.__get_load_requires() || []; + if ( ! requires.includes(path) ) + requires.push(path); - } else if ( name ) { - // Just a Name - const full_name = name; - name = name.replace(/^(?:[^.]*\.)+/, ''); - module = this.resolve(full_name); + this.__reflectDependencies(path, false); + this.__load_injections[key] = path; - // Allow injecting a module that doesn't exist yet? + } else { + const requires = this.requires = this.__get_requires() || []; + if ( ! requires.includes(path) ) + requires.push(path); - if ( ! module || !(module instanceof Module) ) { - if ( module ) - module[2].push([this.__path, variable]); - else - this.__modules[this.abs_path(full_name)] = [[], [], [[this.__path, variable]]] - - requires.push(this.abs_path(full_name)); - - return this[variable] = null; - } - - } else - throw new TypeError(`must provide a valid module name or class`); - - if ( ! module ) - throw new Error(`cannot find module ${name} or no module provided`); - - if ( require ) - requires.push(module.abs_path('.')); - - if ( this.enabled && ! module.enabled ) - module.enable(); - - return this[variable] = module; + this.__reflectDependencies(false, path); + this.__enable_injections[key] = path; + } } - register(name, module, inject_reference) { - if ( name.prototype instanceof Module ) { - inject_reference = module; - module = name; - name = module.name.toSnakeCase(); + /** + * Register a module into the module tree. By default, this does very + * little. When providing a Module class, you can omit the name + * argument. In that case, a name will be inferred from the class name. + * + * When supplying a function or Promise for asynchronous loading, a + * name is required. + * + * The name is always treated as being relative to the current module. + * This is done by prefixing the name with a `.` character. + * + * @param {String} [name] The name of the Module being registered. + * @param {Class|Function|Promise} loader A Module class, or a function or + * promise that will eventually return a Module class. + * @returns {String} The name of the Module. + */ + register(name, loader) { + if ( name && name.prototype instanceof Module ) { + loader = name; + name = null; } - const path = this.abs_path(`.${name}`), - proto = module.prototype, - old_val = this.__modules[path]; + if ( ! name && loader && loader.prototype instanceof Module ) + name = loader.name.toSnakeCase(); - if ( !(proto instanceof Module) ) - throw new TypeError(`Module ${name} is not subclass of Module.`); + if ( ! name || typeof name !== 'string' ) + throw new TypeError('Invalid name'); - if ( old_val instanceof Module ) + // Make sure the name is relative. + name = `.${name}`; + + const path = this.abs_path(name); + if ( this.__modules[path] || this.__module_sources[path] ) throw new ModuleError(`Name Collision for Module ${path}`); - const dependents = old_val || [[], [], []], - inst = this.__modules[path] = new module(name, this), - requires = inst.requires = inst.__get_requires() || [], - load_requires = inst.load_requires = inst.__get_load_requires() || []; - - inst.dependents = dependents[0]; - inst.load_dependents = dependents[1]; - - if ( inst instanceof SiteModule && ! requires.includes('site') ) - requires.push('site'); - - for(const req_name of requires) { - const req_path = inst.abs_path(req_name), - req_mod = this.__modules[req_path]; - - if ( ! req_mod ) - this.__modules[req_path] = [[path],[],[]]; - else if ( Array.isArray(req_mod) ) - req_mod[0].push(path); - else - req_mod.dependents.push(path); - } - - for(const req_name of load_requires) { - const req_path = inst.abs_path(req_name), - req_mod = this.__modules[req_path]; - - if ( ! req_mod ) - this.__modules[req_path] = [[], [path], []]; - else if ( Array.isArray(req_mod) ) - req_mod[1].push(path); - else - req_mod.load_dependents.push(path); - } - - for(const [in_path, in_name] of dependents[2]) { - const in_mod = this.resolve(in_path); - if ( in_mod ) - in_mod[in_name] = inst; - else - this.log.warn(`Unable to find module "${in_path}" that wanted "${in_name}".`); - } - - if ( inject_reference ) - this[name] = inst; - - return inst; + this.__module_sources[path] = loader; + return name; } @@ -695,7 +933,7 @@ export class ModuleError extends Error { } export class CyclicDependencyError extends ModuleError { constructor(message, modules) { - super(`${message} (${modules.map(x => x.path).join(' => ')})`); + super(`${message} ${modules ? `(${modules.map(x => x.path).join(' => ')})` : ''}`); this.modules = modules; } } \ No newline at end of file