diff --git a/src/addons.js b/src/addons.ts similarity index 76% rename from src/addons.js rename to src/addons.ts index 4be77b01..16b7eb96 100644 --- a/src/addons.js +++ b/src/addons.ts @@ -4,21 +4,71 @@ // Add-On System // ============================================================================ -import Module from 'utilities/module'; +import Module, { GenericModule } from 'utilities/module'; import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants'; import { createElement } from 'utilities/dom'; -import { timeout, has, deep_copy } from 'utilities/object'; +import { timeout, has, deep_copy, fetchJSON } from 'utilities/object'; import { getBuster } from 'utilities/time'; +import type SettingsManager from './settings'; +import type TranslationManager from './i18n'; +import type LoadTracker from './load_tracker'; +import type FrankerFaceZ from './main'; +import type { AddonInfo } from 'utilities/types'; + +declare global { + interface Window { + ffzAddonsWebpackJsonp: unknown; + } +} + +declare module 'utilities/types' { + interface ModuleMap { + addons: AddonManager; + } + interface ModuleEventMap { + addons: AddonManagerEvents; + } + interface SettingsTypeMap { + 'addons.dev.server': boolean; + } +}; + +type AddonManagerEvents = { + ':ready': []; + ':data-loaded': []; + ':reload-required': []; + + ':added': [id: string, info: AddonInfo]; + ':addon-loaded': [id: string]; + ':addon-enabled': [id: string]; + ':addon-disabled': [id: string]; + ':fully-unload': [id: string]; +}; -const fetchJSON = (url, options) => fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null); // ============================================================================ // AddonManager // ============================================================================ -export default class AddonManager extends Module { - constructor(...args) { - super(...args); +export default class AddonManager extends Module<'addons'> { + + // Dependencies + i18n: TranslationManager = null as any; + load_tracker: LoadTracker = null as any; + settings: SettingsManager = null as any; + + // State + has_dev: boolean; + reload_required: boolean; + target: string; + + addons: Record; + enabled_addons: string[]; + + private _loader?: Promise; + + constructor(name?: string, parent?: GenericModule) { + super(name, parent); this.should_enable = true; @@ -28,7 +78,7 @@ export default class AddonManager extends Module { this.load_requires = ['settings']; - this.target = this.parent.flavor || 'unknown'; + this.target = (this.parent as unknown as FrankerFaceZ).flavor || 'unknown'; this.has_dev = false; this.reload_required = false; @@ -39,6 +89,7 @@ export default class AddonManager extends Module { } onLoad() { + // We don't actually *wait* for this, we just start it. this._loader = this.loadAddonData(); } @@ -54,20 +105,20 @@ export default class AddonManager extends Module { getFFZ: () => this, isReady: () => this.enabled, getAddons: () => Object.values(this.addons), - hasAddon: id => this.hasAddon(id), - getVersion: id => this.getVersion(id), - doesAddonTarget: id => this.doesAddonTarget(id), - isAddonEnabled: id => this.isAddonEnabled(id), - isAddonExternal: id => this.isAddonExternal(id), - enableAddon: id => this.enableAddon(id), - disableAddon: id => this.disableAddon(id), - reloadAddon: id => this.reloadAddon(id), - canReloadAddon: id => this.canReloadAddon(id), + hasAddon: (id: string) => this.hasAddon(id), + getVersion: (id: string) => this.getVersion(id), + doesAddonTarget: (id: string) => this.doesAddonTarget(id), + isAddonEnabled: (id: string) => this.isAddonEnabled(id), + isAddonExternal: (id: string) => this.isAddonExternal(id), + enableAddon: (id: string) => this.enableAddon(id), + disableAddon: (id: string) => this.disableAddon(id), + reloadAddon: (id: string) => this.reloadAddon(id), + canReloadAddon: (id: string) => this.canReloadAddon(id), isReloadRequired: () => this.reload_required, refresh: () => window.location.reload(), - on: (...args) => this.on(...args), - off: (...args) => this.off(...args) + on: (...args: Parameters) => this.on(...args), + off: (...args: Parameters) => this.off(...args) }); if ( ! EXTENSION ) @@ -85,7 +136,7 @@ export default class AddonManager extends Module { this.settings.provider.on('changed', this.onProviderChange, this); - this._loader.then(() => { + this._loader?.then(() => { this.enabled_addons = this.settings.provider.get('addons.enabled', []); // We do not await enabling add-ons because that would delay the @@ -103,8 +154,8 @@ export default class AddonManager extends Module { } - doesAddonTarget(id) { - const data = this.addons[id]; + doesAddonTarget(id: string) { + const data = this.getAddon(id); if ( ! data ) return false; @@ -118,12 +169,15 @@ export default class AddonManager extends Module { generateLog() { const out = ['Known']; - for(const [id, addon] of Object.entries(this.addons)) + for(const [id, addon] of Object.entries(this.addons)) { + if ( Array.isArray(addon) ) + continue; out.push(`${id} | ${this.isAddonEnabled(id) ? 'enabled' : 'disabled'} | ${addon.dev ? 'dev | ' : ''}${this.isAddonExternal(id) ? 'external | ' : ''}${addon.short_name} v${addon.version}`); + } out.push(''); out.push('Modules'); - for(const [key, module] of Object.entries(this.__modules)) { + for(const [key, module] of Object.entries((this as any).__modules as Record)) { if ( module ) out.push(`${module.loaded ? 'loaded ' : module.loading ? 'loading ' : 'unloaded'} | ${module.enabled ? 'enabled ' : module.enabling ? 'enabling' : 'disabled'} | ${key}`) } @@ -131,22 +185,20 @@ export default class AddonManager extends Module { return out.join('\n'); } - onProviderChange(key, value) { + onProviderChange(key: string, value: unknown) { if ( key != 'addons.enabled' ) return; - if ( ! value ) - value = []; - - const old_enabled = [...this.enabled_addons]; + const val: string[] = Array.isArray(value) ? value : [], + old_enabled = [...this.enabled_addons]; // Add-ons to disable for(const id of old_enabled) - if ( ! value.includes(id) ) + if ( ! val.includes(id) ) this.disableAddon(id, false); // Add-ons to enable - for(const id of value) + for(const id of val) if ( ! old_enabled.includes(id) ) this.enableAddon(id, false); } @@ -187,7 +239,7 @@ export default class AddonManager extends Module { this.emit(':data-loaded'); } - addAddon(addon, is_dev = false) { + addAddon(addon: AddonInfo, is_dev: boolean = false) { const old = this.addons[addon.id]; this.addons[addon.id] = addon; @@ -227,7 +279,7 @@ export default class AddonManager extends Module { getFFZ: () => this }); - this.emit(':added'); + this.emit(':added', addon.id, addon); } rebuildAddonSearch() { @@ -258,39 +310,39 @@ export default class AddonManager extends Module { } } - isAddonEnabled(id) { + isAddonEnabled(id: string) { if ( this.isAddonExternal(id) ) return true; return this.enabled_addons.includes(id); } - getAddon(id) { + getAddon(id: string) { const addon = this.addons[id]; return Array.isArray(addon) ? null : addon; } - hasAddon(id) { + hasAddon(id: string) { return this.getAddon(id) != null; } - getVersion(id) { + getVersion(id: string) { const addon = this.getAddon(id); if ( ! addon ) throw new Error(`Unknown add-on id: ${id}`); const module = this.resolve(`addon.${id}`); if ( module ) { - if ( has(module, 'version') ) + if ( 'version' in module ) // has(module, 'version') ) return module.version; - else if ( module.constructor && has(module.constructor, 'version') ) + else if ( module.constructor && 'version' in module.constructor ) // has(module.constructor, 'version') ) return module.constructor.version; } return addon.version; } - isAddonExternal(id) { + isAddonExternal(id: string) { if ( ! this.hasAddon(id) ) throw new Error(`Unknown add-on id: ${id}`); @@ -306,10 +358,10 @@ export default class AddonManager extends Module { return true; // Finally, let the module flag itself as external. - return module.external || (module.constructor && module.constructor.external); + return (module as any).external || (module.constructor as any)?.external; } - canReloadAddon(id) { + canReloadAddon(id: string) { // Obviously we can't reload it if we don't have it. if ( ! this.hasAddon(id) ) throw new Error(`Unknown add-on id: ${id}`); @@ -334,8 +386,8 @@ export default class AddonManager extends Module { return true; } - async fullyUnloadModule(module) { - if ( ! module ) + async fullyUnloadModule(module: GenericModule) { + if ( ! module || ! module.addon_id ) return; if ( module.children ) @@ -346,47 +398,47 @@ export default class AddonManager extends Module { await module.unload(); // Clean up parent references. - if ( module.parent && module.parent.children[module.name] === module ) + if ( module.parent instanceof Module && module.parent.children[module.name] === module ) delete module.parent.children[module.name]; // Clean up all individual references. for(const entry of module.references) { const other = this.resolve(entry[0]), name = entry[1]; - if ( other && other[name] === module ) - other[name] = null; + if ( (other as any)[name] === module ) + (other as any)[name] = null; } // Send off a signal for other modules to unload related data. - this.emit('addon:fully-unload', module.addon_id); + this.emit(':fully-unload', module.addon_id); // Clean up the global reference. - if ( this.__modules[module.__path] === module ) - delete this.__modules[module.__path]; /* = [ + if ( (this as any).__modules[(module as any).__path] === module ) + delete (this as any).__modules[(module as any).__path]; /* = [ module.dependents, module.load_dependents, module.references ];*/ // Remove any events we didn't unregister. - this.offContext(null, module); + this.off(undefined, undefined, module); // Do the same for settings. for(const ctx of this.settings.__contexts) - ctx.offContext(null, module); + ctx.off(undefined, undefined, module); // Clean up all settings. for(const [key, def] of Array.from(this.settings.definitions.entries())) { - if ( def && def.__source === module.addon_id ) { + if ( ! Array.isArray(def) && def?.__source === module.addon_id ) { this.settings.remove(key); } } // Clean up the logger too. - module.__log = null; + (module as any).__log = null; } - async reloadAddon(id) { + async reloadAddon(id: string) { const addon = this.getAddon(id), button = this.resolve('site.menu_button'); if ( ! addon ) @@ -456,7 +508,7 @@ export default class AddonManager extends Module { }); } - async _enableAddon(id) { + private async _enableAddon(id: string) { const addon = this.getAddon(id); if ( ! addon ) throw new Error(`Unknown add-on id: ${id}`); @@ -476,7 +528,7 @@ export default class AddonManager extends Module { this.load_tracker.notify(event, `addon.${id}`, false); } - async loadAddon(id) { + async loadAddon(id: string) { const addon = this.getAddon(id); if ( ! addon ) throw new Error(`Unknown add-on id: ${id}`); @@ -500,7 +552,7 @@ export default class AddonManager extends Module { })); // Error if this takes more than 5 seconds. - await timeout(this.waitFor(`addon.${id}:registered`), 60000); + await timeout(this.waitFor(`addon.${id}:registered` as any), 60000); module = this.resolve(`addon.${id}`); if ( module && ! module.loaded ) @@ -509,13 +561,13 @@ export default class AddonManager extends Module { this.emit(':addon-loaded', id); } - unloadAddon(id) { + unloadAddon(id: string) { const module = this.resolve(`addon.${id}`); if ( module ) return module.unload(); } - enableAddon(id, save = true) { + enableAddon(id: string, save: boolean = true) { const addon = this.getAddon(id); if( ! addon ) throw new Error(`Unknown add-on id: ${id}`); @@ -546,7 +598,7 @@ export default class AddonManager extends Module { }); } - async disableAddon(id, save = true) { + async disableAddon(id: string, save: boolean = true) { const addon = this.getAddon(id); if ( ! addon ) throw new Error(`Unknown add-on id: ${id}`); diff --git a/src/experiments.ts b/src/experiments.ts index 3c3f80fc..b5ee99df 100644 --- a/src/experiments.ts +++ b/src/experiments.ts @@ -18,6 +18,9 @@ declare module 'utilities/types' { interface ModuleMap { experiments: ExperimentManager; } + interface ModuleEventMap { + experiments: ExperimentEvents; + } interface ProviderTypeMap { 'experiment-overrides': Record } diff --git a/src/pubsub/index.js b/src/pubsub/index.ts similarity index 71% rename from src/pubsub/index.js rename to src/pubsub/index.ts index 9c5b7576..b7d5c1d6 100644 --- a/src/pubsub/index.js +++ b/src/pubsub/index.ts @@ -4,13 +4,57 @@ // PubSub Client // ============================================================================ -import Module from 'utilities/module'; +import Module, { GenericModule } from 'utilities/module'; import { PUBSUB_CLUSTERS } from 'utilities/constants'; +import type ExperimentManager from '../experiments'; +import type SettingsManager from '../settings'; +import type PubSubClient from 'utilities/pubsub'; +import type { PubSubCommands } from 'utilities/types'; + +declare module 'utilities/types' { + interface ModuleMap { + pubsub: PubSub; + } + interface ModuleEventMap { + pubsub: PubSubEvents; + } + interface SettingsTypeMap { + 'pubsub.use-cluster': keyof typeof PUBSUB_CLUSTERS | null; + } +} -export default class PubSub extends Module { - constructor(...args) { - super(...args); +type PubSubCommandData = { + topic: string; + cmd: K; + data: PubSubCommands[K]; +}; + +type PubSubCommandKey = `:command:${keyof PubSubCommands}`; + +type PubSubEvents = { + ':sub-change': []; + ':message': [topic: string, data: unknown]; +} & { + [K in keyof PubSubCommands as `:command:${K}`]: [data: PubSubCommands[K], meta: PubSubCommandData]; +} + + +export default class PubSub extends Module<'pubsub', PubSubEvents> { + + // Dependencies + experiments: ExperimentManager = null as any; + settings: SettingsManager = null as any; + + // State + _topics: Map>; + _client: PubSubClient | null; + + _mqtt?: typeof PubSubClient | null; + _mqtt_loader?: Promise | null; + + constructor(name?: string, parent?: GenericModule) { + super(name, parent); this.inject('settings'); this.inject('experiments'); @@ -161,18 +205,18 @@ export default class PubSub extends Module { client.on('message', event => { const topic = event.topic, - data = event.data; + data = event.data as PubSubCommandData; if ( ! data?.cmd ) { this.log.debug(`Received message on topic "${topic}":`, data); - this.emit(`pubsub:message`, topic, data); + this.emit(`:message`, topic, data); return; } data.topic = topic; this.log.debug(`Received command on topic "${topic}" for command "${data.cmd}":`, data.data); - this.emit(`pubsub:command:${data.cmd}`, data.data, data); + this.emit(`:command:${data.cmd}` as PubSubCommandKey, data.data, data); }); // Subscribe to topics. @@ -196,20 +240,23 @@ export default class PubSub extends Module { // Topics // ======================================================================== - subscribe(referrer, ...topics) { - const t = this._topics; + subscribe(referrer: unknown, ...topics: string[]) { + const topic_map = this._topics; let changed = false; for(const topic of topics) { - if ( ! t.has(topic) ) { + let refs = topic_map.get(topic); + if ( refs ) + refs.add(referrer); + else { if ( this._client ) this._client.subscribe(topic); - t.set(topic, new Set); + refs = new Set; + refs.add(referrer); + + topic_map.set(topic, refs); changed = true; } - - const tp = t.get(topic); - tp.add(referrer); } if ( changed ) @@ -217,19 +264,19 @@ export default class PubSub extends Module { } - unsubscribe(referrer, ...topics) { - const t = this._topics; + unsubscribe(referrer: unknown, ...topics: string[]) { + const topic_map = this._topics; let changed = false; for(const topic of topics) { - if ( ! t.has(topic) ) + const refs = topic_map.get(topic); + if ( ! refs ) continue; - const tp = t.get(topic); - tp.delete(referrer); + refs.delete(referrer); - if ( ! tp.size ) { + if ( ! refs.size ) { changed = true; - t.delete(topic); + topic_map.delete(topic); if ( this._client ) this._client.unsubscribe(topic); } diff --git a/src/settings/components/basic-toggle.vue b/src/settings/components/basic-toggle.vue index 85c8cbad..5490bd5c 100644 --- a/src/settings/components/basic-toggle.vue +++ b/src/settings/components/basic-toggle.vue @@ -30,4 +30,4 @@ export default { } } - \ No newline at end of file + diff --git a/src/staging.jsx b/src/staging.tsx similarity index 53% rename from src/staging.jsx rename to src/staging.tsx index c46f46c3..6b0afefc 100644 --- a/src/staging.jsx +++ b/src/staging.tsx @@ -4,12 +4,38 @@ // Staging Selector // ============================================================================ -import Module from 'utilities/module'; +import Module, { GenericModule } from 'utilities/module'; import { API_SERVER, SERVER, STAGING_API, STAGING_CDN } from './utilities/constants'; +import type SettingsManager from './settings'; -export default class StagingSelector extends Module { - constructor(...args) { - super(...args); +declare module 'utilities/types' { + interface ModuleMap { + staging: StagingSelector; + } + interface ModuleEventMap { + staging: StagingEvents; + } + interface SettingsTypeMap { + 'data.use-staging': boolean; + } +} + +type StagingEvents = { + ':updated': [api: string, cdn: string]; +} + +export default class StagingSelector extends Module<'staging', StagingEvents> { + + // Dependencies + settings: SettingsManager = null as any; + + // State + api: string = API_SERVER; + cdn: string = SERVER; + active: boolean = false; + + constructor(name?: string, parent?: GenericModule) { + super(name, parent); this.inject('settings'); @@ -26,11 +52,12 @@ export default class StagingSelector extends Module { this.updateStaging(false); } + /** @internal */ onEnable() { this.settings.getChanges('data.use-staging', this.updateStaging, this); } - updateStaging(val) { + private updateStaging(val: boolean) { this.active = val; this.api = val @@ -43,4 +70,4 @@ export default class StagingSelector extends Module { this.emit(':updated', this.api, this.cdn); } -} \ No newline at end of file +} diff --git a/src/utilities/types.ts b/src/utilities/types.ts index 37467963..81ebe621 100644 --- a/src/utilities/types.ts +++ b/src/utilities/types.ts @@ -225,6 +225,10 @@ export interface ProviderTypeMap { }; +export interface PubSubCommands { + +}; + // TODO: Move this event into addons. @@ -247,15 +251,10 @@ export interface ModuleMap { 'i18n': TranslationManager; 'link_card': LinkCard; 'main_menu': MainMenu; - 'pubsub': PubSub; 'site.apollo': Apollo; 'site.css_tweaks': CSSTweaks; - 'site.elemental': Elemental; - 'site.fine': Fine; - 'site.twitch_data': TwitchData; 'site.web_munch': WebMunch; 'socket': SocketClient; - 'staging': StagingSelector; 'translation_ui': TranslationUI; };