/** * * Agora Real Time Engagement * Created by Wei Hu in 2021-11. * Copyright (c) 2022 Agora IO. All rights reserved. * */ import * as fs from 'fs'; import * as path from 'path'; import { callsites } from '../common/fs'; import * as log from '../common/logger'; import { Extension } from '../extension/extension'; import { ExtensionGroup } from '../extension_group/extension_group'; import { MetadataInfo, MetadataType } from '../metadata/metadata'; import { RTE } from '../rte/rte'; import rte_addon from '../rte_addon'; import { CjsModulesGenerator } from './bundle'; type Ctor = { new (name: string, ...args: unknown[]): T; prototype: T; // @{ // Following are the 3 static functions defined in both the class of // Extension and ExtensionGroup. They are mandatory for the registration of // the class of Proc and ExtensionGroup. onInit: | undefined | (( rte: RTE, manifest: MetadataInfo, property: MetadataInfo ) => Promise); createInstance: (name: string) => T; destroyInstance: (instance: T) => void; // @} }; type ExtensionOrExtensionGroup = Extension | ExtensionGroup; const factories: Record> = {}; function RemoveAddon(name: string): void { if (factories[name]) { rte_addon.rte_nodejs_addon_unregister(name, factories[name]); delete factories[name]; } } /* * Usage: * * @Addon('foo') * class MyExtension extends Extension { * // If not given, use addon name to register * @override static onInit(manifest: MetadataInfo, property: MetadataInfo) { * manifest.set(MetadataType.JSON_FILE, __dirname + '../manifest.json'); * } * } * * Note: The 'name' parameter must be specified if the Addon would be packaged * through bundlers, ex: webpack. */ export function Addon( name?: string, ): >(klass: T) => T { // If the addon is dynamically required, the outer context would add the // location of the addon folder (i.e., the following // __rte_addon_absolute_path__) to the 'global' object, so that we can fetch // that value from 'global' object here. let addonFolder: string | undefined = (global as any) .__rte_addon_absolute_path__; if (addonFolder) { log.debug(`Addon ${addonFolder} is dynamically required.`); } // If the addon name is not specified in the parameter, we need to get // the name from the manifest.json of that addon. We use V8 stack trace // API to accomplish that. if (!addonFolder && !name) { const callsite = callsites()[1]; const addonFilename = callsite ? callsite.getFileName() : undefined; if (addonFilename) { addonFolder = path.resolve(path.dirname(addonFilename), '../'); } } return function >(klass: T): T { const isExtension = klass.prototype instanceof Extension; if (!isExtension && !(klass.prototype instanceof ExtensionGroup)) { throw new Error( 'Decorator can only be used on Extension or ExtensionGroup', ); } function createInstanceProxy( this: Ctor, instanceName: string, ): ExtensionOrExtensionGroup { const extensionOrExtensionGroup = this.createInstance(instanceName); extensionOrExtensionGroup.addonFolder = addonFolder; return extensionOrExtensionGroup; } const { onInit, destroyInstance } = klass; const defaultOnInit = function ( rte: RTE, m: MetadataInfo, _p: MetadataInfo, ): void { let addonName: string | undefined = undefined; // If the addon name is not specified in the parameter, we need to // get the name from the manifest.json of that addon. if (name === undefined) { if (fs.existsSync(`${addonFolder}/manifest.json`)) { const data = fs.readFileSync(`${addonFolder}/manifest.json`, 'utf8'); const manifest_json = JSON.parse(data); if (manifest_json.name) { addonName = manifest_json.name; } m.set(MetadataType.JSON_FILENAME, `${addonFolder}/manifest.json`); } } // If the name of the addon is still undefined, and a name is // specified in the parameter, we will use that name instead. if (addonName === undefined && name !== undefined) { addonName = name; m.set(MetadataType.JSON_STR, JSON.stringify({ name })); } if (addonName === undefined) { // This is really an error condition, RTE world can not continue. throw new Error(`Failed to find addon name.`); } if (factories[addonName]) { throw new Error( `The '${addonName}' addon has already been registered.`, ); } factories[addonName] = klass; // Check if this RTE addon is run in the browser environment, if // yes, when the browser close or reload, all JavaScript instances are // gone, so we need to unregister the JS addons from the RTE // addon store. if (typeof window !== 'undefined') { // RTE addon is executed in the browser environment. window.addEventListener('beforeunload', () => { if (addonName) { RemoveAddon(addonName); } }); } }; const _onInit = async function ( rte: RTE, manifest: MetadataInfo, property: MetadataInfo, ): Promise { try { if (onInit) { await onInit(rte, manifest, property); } else { defaultOnInit(rte, manifest, property); } } catch (e) { log.error(`Addon onInit throws an exception: ${e}`); } finally { rte_addon.rte_nodejs_rte_on_init_done(rte, manifest, property); rte_addon.rte_nodejs_addon_on_init_done(klass); } }; rte_addon.rte_nodejs_addon_register( name, isExtension, klass, _onInit, createInstanceProxy, destroyInstance, ); return klass; }; } Addon.unload = function (name: string): void { RemoveAddon(name); }; Addon.unloadAll = function (): void { const allAddons = Addon.loaded(); for (const addon of allAddons) { Addon.unload(addon); } // Addon.unloadAll() will be called when the RTE app ends, so we explicitly // trigger garbage collection here to let Node.js could be stopped and exit. (global as any).gc(); }; Addon.loaded = function (): string[] { const res = []; for (const k in factories) { res.push(k); } return res; }; // In order to maintain the consistency of the interface with react native, we // add the second parameter, it is useless now. Addon.loadAll = function ( appRoot: string, _modulesDependsOn: CjsModulesGenerator = {}, ): Promise<[void, void]> { async function loadAll(folder: string): Promise { if (fs.existsSync(folder)) { module.paths.unshift(folder); const dirs = fs.opendirSync(folder); for (;;) { const entry = dirs.readSync(); if (!entry) { break; } if (entry.name.startsWith('.')) { continue; } const packageJson = `${folder}/${entry.name}/package.json`; if ( (entry.isDirectory() || // The extension directory maybe a symbolic link in dev mode, and // 'entry.isDirectory()' will return false. entry.isSymbolicLink()) && fs.existsSync(packageJson) ) { // If enabling '"resolveJsonModule": true' in tsconfig.json, we can // use 'import entry.name' below. However, this method has a // restriction which needs all your imported JSONs must reside under // the "rootDir". Unfortunately the "rootDir" is very often a folder // beneath package.json like './src' and things would fail. So we // have to use 'require' here. ( global as any ).__rte_addon_absolute_path__ = `${folder}/${entry.name}/`; require(entry.name); (global as any).__rte_addon_absolute_path__ = undefined; } } dirs.closeSync(); } } return Promise.all([ loadAll(`${appRoot}/addon/extension_group`), loadAll(`${appRoot}/addon/extension`), ]); };