1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00

* Convert AddonManager to TypeScript.

* Convert PubSub (not PubSubClient) to TypeScript.
* Convert StagingSelector to TypeScript.
* Make sure to add ExperimentManager's events to the global interface.
This commit is contained in:
SirStendec 2023-11-16 22:36:10 -05:00
parent 6c6d4ceb98
commit 31e7ce4ac5
6 changed files with 221 additions and 93 deletions

View file

@ -4,21 +4,71 @@
// Add-On System // Add-On System
// ============================================================================ // ============================================================================
import Module from 'utilities/module'; import Module, { GenericModule } from 'utilities/module';
import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants'; import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants';
import { createElement } from 'utilities/dom'; 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 { 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 // AddonManager
// ============================================================================ // ============================================================================
export default class AddonManager extends Module { export default class AddonManager extends Module<'addons'> {
constructor(...args) {
super(...args); // 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<string, AddonInfo | string[]>;
enabled_addons: string[];
private _loader?: Promise<void>;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.should_enable = true; this.should_enable = true;
@ -28,7 +78,7 @@ export default class AddonManager extends Module {
this.load_requires = ['settings']; 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.has_dev = false;
this.reload_required = false; this.reload_required = false;
@ -39,6 +89,7 @@ export default class AddonManager extends Module {
} }
onLoad() { onLoad() {
// We don't actually *wait* for this, we just start it.
this._loader = this.loadAddonData(); this._loader = this.loadAddonData();
} }
@ -54,20 +105,20 @@ export default class AddonManager extends Module {
getFFZ: () => this, getFFZ: () => this,
isReady: () => this.enabled, isReady: () => this.enabled,
getAddons: () => Object.values(this.addons), getAddons: () => Object.values(this.addons),
hasAddon: id => this.hasAddon(id), hasAddon: (id: string) => this.hasAddon(id),
getVersion: id => this.getVersion(id), getVersion: (id: string) => this.getVersion(id),
doesAddonTarget: id => this.doesAddonTarget(id), doesAddonTarget: (id: string) => this.doesAddonTarget(id),
isAddonEnabled: id => this.isAddonEnabled(id), isAddonEnabled: (id: string) => this.isAddonEnabled(id),
isAddonExternal: id => this.isAddonExternal(id), isAddonExternal: (id: string) => this.isAddonExternal(id),
enableAddon: id => this.enableAddon(id), enableAddon: (id: string) => this.enableAddon(id),
disableAddon: id => this.disableAddon(id), disableAddon: (id: string) => this.disableAddon(id),
reloadAddon: id => this.reloadAddon(id), reloadAddon: (id: string) => this.reloadAddon(id),
canReloadAddon: id => this.canReloadAddon(id), canReloadAddon: (id: string) => this.canReloadAddon(id),
isReloadRequired: () => this.reload_required, isReloadRequired: () => this.reload_required,
refresh: () => window.location.reload(), refresh: () => window.location.reload(),
on: (...args) => this.on(...args), on: (...args: Parameters<typeof this.on>) => this.on(...args),
off: (...args) => this.off(...args) off: (...args: Parameters<typeof this.off>) => this.off(...args)
}); });
if ( ! EXTENSION ) if ( ! EXTENSION )
@ -85,7 +136,7 @@ export default class AddonManager extends Module {
this.settings.provider.on('changed', this.onProviderChange, this); this.settings.provider.on('changed', this.onProviderChange, this);
this._loader.then(() => { this._loader?.then(() => {
this.enabled_addons = this.settings.provider.get('addons.enabled', []); this.enabled_addons = this.settings.provider.get('addons.enabled', []);
// We do not await enabling add-ons because that would delay the // We do not await enabling add-ons because that would delay the
@ -103,8 +154,8 @@ export default class AddonManager extends Module {
} }
doesAddonTarget(id) { doesAddonTarget(id: string) {
const data = this.addons[id]; const data = this.getAddon(id);
if ( ! data ) if ( ! data )
return false; return false;
@ -118,12 +169,15 @@ export default class AddonManager extends Module {
generateLog() { generateLog() {
const out = ['Known']; 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(`${id} | ${this.isAddonEnabled(id) ? 'enabled' : 'disabled'} | ${addon.dev ? 'dev | ' : ''}${this.isAddonExternal(id) ? 'external | ' : ''}${addon.short_name} v${addon.version}`);
}
out.push(''); out.push('');
out.push('Modules'); 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<string, GenericModule>)) {
if ( module ) if ( module )
out.push(`${module.loaded ? 'loaded ' : module.loading ? 'loading ' : 'unloaded'} | ${module.enabled ? 'enabled ' : module.enabling ? 'enabling' : 'disabled'} | ${key}`) 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'); return out.join('\n');
} }
onProviderChange(key, value) { onProviderChange(key: string, value: unknown) {
if ( key != 'addons.enabled' ) if ( key != 'addons.enabled' )
return; return;
if ( ! value ) const val: string[] = Array.isArray(value) ? value : [],
value = []; old_enabled = [...this.enabled_addons];
const old_enabled = [...this.enabled_addons];
// Add-ons to disable // Add-ons to disable
for(const id of old_enabled) for(const id of old_enabled)
if ( ! value.includes(id) ) if ( ! val.includes(id) )
this.disableAddon(id, false); this.disableAddon(id, false);
// Add-ons to enable // Add-ons to enable
for(const id of value) for(const id of val)
if ( ! old_enabled.includes(id) ) if ( ! old_enabled.includes(id) )
this.enableAddon(id, false); this.enableAddon(id, false);
} }
@ -187,7 +239,7 @@ export default class AddonManager extends Module {
this.emit(':data-loaded'); this.emit(':data-loaded');
} }
addAddon(addon, is_dev = false) { addAddon(addon: AddonInfo, is_dev: boolean = false) {
const old = this.addons[addon.id]; const old = this.addons[addon.id];
this.addons[addon.id] = addon; this.addons[addon.id] = addon;
@ -227,7 +279,7 @@ export default class AddonManager extends Module {
getFFZ: () => this getFFZ: () => this
}); });
this.emit(':added'); this.emit(':added', addon.id, addon);
} }
rebuildAddonSearch() { rebuildAddonSearch() {
@ -258,39 +310,39 @@ export default class AddonManager extends Module {
} }
} }
isAddonEnabled(id) { isAddonEnabled(id: string) {
if ( this.isAddonExternal(id) ) if ( this.isAddonExternal(id) )
return true; return true;
return this.enabled_addons.includes(id); return this.enabled_addons.includes(id);
} }
getAddon(id) { getAddon(id: string) {
const addon = this.addons[id]; const addon = this.addons[id];
return Array.isArray(addon) ? null : addon; return Array.isArray(addon) ? null : addon;
} }
hasAddon(id) { hasAddon(id: string) {
return this.getAddon(id) != null; return this.getAddon(id) != null;
} }
getVersion(id) { getVersion(id: string) {
const addon = this.getAddon(id); const addon = this.getAddon(id);
if ( ! addon ) if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`); throw new Error(`Unknown add-on id: ${id}`);
const module = this.resolve(`addon.${id}`); const module = this.resolve(`addon.${id}`);
if ( module ) { if ( module ) {
if ( has(module, 'version') ) if ( 'version' in module ) // has(module, 'version') )
return 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 module.constructor.version;
} }
return addon.version; return addon.version;
} }
isAddonExternal(id) { isAddonExternal(id: string) {
if ( ! this.hasAddon(id) ) if ( ! this.hasAddon(id) )
throw new Error(`Unknown add-on id: ${id}`); throw new Error(`Unknown add-on id: ${id}`);
@ -306,10 +358,10 @@ export default class AddonManager extends Module {
return true; return true;
// Finally, let the module flag itself as external. // 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. // Obviously we can't reload it if we don't have it.
if ( ! this.hasAddon(id) ) if ( ! this.hasAddon(id) )
throw new Error(`Unknown add-on id: ${id}`); throw new Error(`Unknown add-on id: ${id}`);
@ -334,8 +386,8 @@ export default class AddonManager extends Module {
return true; return true;
} }
async fullyUnloadModule(module) { async fullyUnloadModule(module: GenericModule) {
if ( ! module ) if ( ! module || ! module.addon_id )
return; return;
if ( module.children ) if ( module.children )
@ -346,47 +398,47 @@ export default class AddonManager extends Module {
await module.unload(); await module.unload();
// Clean up parent references. // 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]; delete module.parent.children[module.name];
// Clean up all individual references. // Clean up all individual references.
for(const entry of module.references) { for(const entry of module.references) {
const other = this.resolve(entry[0]), const other = this.resolve(entry[0]),
name = entry[1]; name = entry[1];
if ( other && other[name] === module ) if ( (other as any)[name] === module )
other[name] = null; (other as any)[name] = null;
} }
// Send off a signal for other modules to unload related data. // 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. // Clean up the global reference.
if ( this.__modules[module.__path] === module ) if ( (this as any).__modules[(module as any).__path] === module )
delete this.__modules[module.__path]; /* = [ delete (this as any).__modules[(module as any).__path]; /* = [
module.dependents, module.dependents,
module.load_dependents, module.load_dependents,
module.references module.references
];*/ ];*/
// Remove any events we didn't unregister. // Remove any events we didn't unregister.
this.offContext(null, module); this.off(undefined, undefined, module);
// Do the same for settings. // Do the same for settings.
for(const ctx of this.settings.__contexts) for(const ctx of this.settings.__contexts)
ctx.offContext(null, module); ctx.off(undefined, undefined, module);
// Clean up all settings. // Clean up all settings.
for(const [key, def] of Array.from(this.settings.definitions.entries())) { 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); this.settings.remove(key);
} }
} }
// Clean up the logger too. // 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), const addon = this.getAddon(id),
button = this.resolve('site.menu_button'); button = this.resolve('site.menu_button');
if ( ! addon ) 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); const addon = this.getAddon(id);
if ( ! addon ) if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`); 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); this.load_tracker.notify(event, `addon.${id}`, false);
} }
async loadAddon(id) { async loadAddon(id: string) {
const addon = this.getAddon(id); const addon = this.getAddon(id);
if ( ! addon ) if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`); 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. // 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}`); module = this.resolve(`addon.${id}`);
if ( module && ! module.loaded ) if ( module && ! module.loaded )
@ -509,13 +561,13 @@ export default class AddonManager extends Module {
this.emit(':addon-loaded', id); this.emit(':addon-loaded', id);
} }
unloadAddon(id) { unloadAddon(id: string) {
const module = this.resolve(`addon.${id}`); const module = this.resolve(`addon.${id}`);
if ( module ) if ( module )
return module.unload(); return module.unload();
} }
enableAddon(id, save = true) { enableAddon(id: string, save: boolean = true) {
const addon = this.getAddon(id); const addon = this.getAddon(id);
if( ! addon ) if( ! addon )
throw new Error(`Unknown add-on id: ${id}`); 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); const addon = this.getAddon(id);
if ( ! addon ) if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`); throw new Error(`Unknown add-on id: ${id}`);

View file

@ -18,6 +18,9 @@ declare module 'utilities/types' {
interface ModuleMap { interface ModuleMap {
experiments: ExperimentManager; experiments: ExperimentManager;
} }
interface ModuleEventMap {
experiments: ExperimentEvents;
}
interface ProviderTypeMap { interface ProviderTypeMap {
'experiment-overrides': Record<string, unknown> 'experiment-overrides': Record<string, unknown>
} }

View file

@ -4,13 +4,57 @@
// PubSub Client // PubSub Client
// ============================================================================ // ============================================================================
import Module from 'utilities/module'; import Module, { GenericModule } from 'utilities/module';
import { PUBSUB_CLUSTERS } from 'utilities/constants'; 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 { type PubSubCommandData<K extends keyof PubSubCommands> = {
constructor(...args) { topic: string;
super(...args); 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<K>];
}
export default class PubSub extends Module<'pubsub', PubSubEvents> {
// Dependencies
experiments: ExperimentManager = null as any;
settings: SettingsManager = null as any;
// State
_topics: Map<string, Set<unknown>>;
_client: PubSubClient | null;
_mqtt?: typeof PubSubClient | null;
_mqtt_loader?: Promise<typeof PubSubClient> | null;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.inject('settings'); this.inject('settings');
this.inject('experiments'); this.inject('experiments');
@ -161,18 +205,18 @@ export default class PubSub extends Module {
client.on('message', event => { client.on('message', event => {
const topic = event.topic, const topic = event.topic,
data = event.data; data = event.data as PubSubCommandData<any>;
if ( ! data?.cmd ) { if ( ! data?.cmd ) {
this.log.debug(`Received message on topic "${topic}":`, data); this.log.debug(`Received message on topic "${topic}":`, data);
this.emit(`pubsub:message`, topic, data); this.emit(`:message`, topic, data);
return; return;
} }
data.topic = topic; data.topic = topic;
this.log.debug(`Received command on topic "${topic}" for command "${data.cmd}":`, data.data); 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. // Subscribe to topics.
@ -196,20 +240,23 @@ export default class PubSub extends Module {
// Topics // Topics
// ======================================================================== // ========================================================================
subscribe(referrer, ...topics) { subscribe(referrer: unknown, ...topics: string[]) {
const t = this._topics; const topic_map = this._topics;
let changed = false; let changed = false;
for(const topic of topics) { for(const topic of topics) {
if ( ! t.has(topic) ) { let refs = topic_map.get(topic);
if ( refs )
refs.add(referrer);
else {
if ( this._client ) if ( this._client )
this._client.subscribe(topic); this._client.subscribe(topic);
t.set(topic, new Set); refs = new Set;
refs.add(referrer);
topic_map.set(topic, refs);
changed = true; changed = true;
} }
const tp = t.get(topic);
tp.add(referrer);
} }
if ( changed ) if ( changed )
@ -217,19 +264,19 @@ export default class PubSub extends Module {
} }
unsubscribe(referrer, ...topics) { unsubscribe(referrer: unknown, ...topics: string[]) {
const t = this._topics; const topic_map = this._topics;
let changed = false; let changed = false;
for(const topic of topics) { for(const topic of topics) {
if ( ! t.has(topic) ) const refs = topic_map.get(topic);
if ( ! refs )
continue; continue;
const tp = t.get(topic); refs.delete(referrer);
tp.delete(referrer);
if ( ! tp.size ) { if ( ! refs.size ) {
changed = true; changed = true;
t.delete(topic); topic_map.delete(topic);
if ( this._client ) if ( this._client )
this._client.unsubscribe(topic); this._client.unsubscribe(topic);
} }

View file

@ -30,4 +30,4 @@ export default {
} }
} }
</script> </script>

View file

@ -4,12 +4,38 @@
// Staging Selector // 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 { API_SERVER, SERVER, STAGING_API, STAGING_CDN } from './utilities/constants';
import type SettingsManager from './settings';
export default class StagingSelector extends Module { declare module 'utilities/types' {
constructor(...args) { interface ModuleMap {
super(...args); 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'); this.inject('settings');
@ -26,11 +52,12 @@ export default class StagingSelector extends Module {
this.updateStaging(false); this.updateStaging(false);
} }
/** @internal */
onEnable() { onEnable() {
this.settings.getChanges('data.use-staging', this.updateStaging, this); this.settings.getChanges('data.use-staging', this.updateStaging, this);
} }
updateStaging(val) { private updateStaging(val: boolean) {
this.active = val; this.active = val;
this.api = val this.api = val
@ -43,4 +70,4 @@ export default class StagingSelector extends Module {
this.emit(':updated', this.api, this.cdn); this.emit(':updated', this.api, this.cdn);
} }
} }

View file

@ -225,6 +225,10 @@ export interface ProviderTypeMap {
}; };
export interface PubSubCommands {
};
// TODO: Move this event into addons. // TODO: Move this event into addons.
@ -247,15 +251,10 @@ export interface ModuleMap {
'i18n': TranslationManager; 'i18n': TranslationManager;
'link_card': LinkCard; 'link_card': LinkCard;
'main_menu': MainMenu; 'main_menu': MainMenu;
'pubsub': PubSub;
'site.apollo': Apollo; 'site.apollo': Apollo;
'site.css_tweaks': CSSTweaks; 'site.css_tweaks': CSSTweaks;
'site.elemental': Elemental;
'site.fine': Fine;
'site.twitch_data': TwitchData;
'site.web_munch': WebMunch; 'site.web_munch': WebMunch;
'socket': SocketClient; 'socket': SocketClient;
'staging': StagingSelector;
'translation_ui': TranslationUI; 'translation_ui': TranslationUI;
}; };