blob: 3935ef1434f3c102dd97005faa9ec9bb6ab5a4f3 [file] [log] [blame]
/**
* @license
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {importHref} from '../../../scripts/import-href';
import {HookApi, PluginApi} from '../../plugins/gr-plugin-types';
import {notUndefined} from '../../../types/types';
type Callback = (value: any) => void;
export interface ModuleInfo {
moduleName: string;
plugin: PluginApi;
pluginUrl?: URL;
type?: string;
domHook?: HookApi;
slot?: string;
}
interface Options {
endpoint: string;
dynamicEndpoint?: string;
slot?: string;
type?: string;
moduleName?: string;
domHook?: HookApi;
}
export class GrPluginEndpoints {
private readonly _endpoints = new Map<string, ModuleInfo[]>();
private readonly _callbacks = new Map<string, ((value: any) => void)[]>();
private readonly _dynamicPlugins = new Map<string, Set<string>>();
private readonly _importedUrls = new Set<string>();
private _pluginLoaded = false;
setPluginsReady() {
this._pluginLoaded = true;
}
onNewEndpoint(endpoint: string, callback: Callback) {
if (!this._callbacks.has(endpoint)) {
this._callbacks.set(endpoint, []);
}
this._callbacks.get(endpoint)!.push(callback);
}
onDetachedEndpoint(endpoint: string, callback: Callback) {
if (this._callbacks.has(endpoint)) {
const filteredCallbacks = this._callbacks
.get(endpoint)!
.filter((cb: Callback) => cb !== callback);
this._callbacks.set(endpoint, filteredCallbacks);
}
}
_getOrCreateModuleInfo(plugin: PluginApi, opts: Options): ModuleInfo {
const {endpoint, slot, type, moduleName, domHook} = opts;
const existingModule = this._endpoints
.get(endpoint!)!
.find(
(info: ModuleInfo) =>
info.plugin === plugin &&
info.moduleName === moduleName &&
info.domHook === domHook &&
info.slot === slot
);
if (existingModule) {
return existingModule;
} else {
const newModule: ModuleInfo = {
moduleName: moduleName!,
plugin,
pluginUrl: plugin._url,
type,
domHook,
slot,
};
this._endpoints.get(endpoint!)!.push(newModule);
return newModule;
}
}
/**
* Register a plugin to an endpoint.
*
* Dynamic plugins are registered to a specific prefix, such as
* 'change-list-header'. These plugins are then fetched by prefix to determine
* which endpoints to dynamically add to the page.
*/
registerModule(plugin: PluginApi, opts: Options) {
const endpoint = opts.endpoint!;
const dynamicEndpoint = opts.dynamicEndpoint;
if (dynamicEndpoint) {
if (!this._dynamicPlugins.has(dynamicEndpoint)) {
this._dynamicPlugins.set(dynamicEndpoint, new Set());
}
this._dynamicPlugins.get(dynamicEndpoint)!.add(endpoint);
}
if (!this._endpoints.has(endpoint)) {
this._endpoints.set(endpoint, []);
}
const moduleInfo = this._getOrCreateModuleInfo(plugin, opts);
// TODO: the logic below seems wrong when:
// multiple plugins register to the same endpoint
// one register before plugins ready
// the other done after, then only the later one will have the callbacks
// invoked.
if (this._pluginLoaded && this._callbacks.has(endpoint)) {
this._callbacks.get(endpoint)!.forEach(callback => callback(moduleInfo));
}
}
getDynamicEndpoints(dynamicEndpoint: string): string[] {
const plugins = this._dynamicPlugins.get(dynamicEndpoint);
if (!plugins) return [];
return Array.from(plugins);
}
/**
* Get detailed information about modules registered with an extension
* endpoint.
*/
getDetails(name: string, options?: Options): ModuleInfo[] {
const type = options && options.type;
const moduleName = options && options.moduleName;
if (!this._endpoints.has(name)) {
return [];
} else {
return this._endpoints
.get(name)!
.filter(
(item: ModuleInfo) =>
(!type || item.type === type) &&
(!moduleName || moduleName === item.moduleName)
);
}
}
/**
* Get detailed module names for instantiating at the endpoint.
*/
getModules(name: string, options?: Options): string[] {
const modulesData = this.getDetails(name, options);
if (!modulesData.length) {
return [];
}
return modulesData.map(m => m.moduleName);
}
/**
* Get plugin URLs with element and module definitions.
*/
getPlugins(name: string, options?: Options): URL[] {
const modulesData = this.getDetails(name, options);
if (!modulesData.length) {
return [];
}
return Array.from(new Set(modulesData.map(m => m.pluginUrl))).filter(
notUndefined
);
}
importUrl(pluginUrl: URL) {
let timerId: any;
return Promise.race([
new Promise((resolve, reject) => {
this._importedUrls.add(pluginUrl.href);
importHref(pluginUrl.href, resolve, reject);
}),
// Timeout after 3s
new Promise(r => (timerId = setTimeout(r, 3000))),
]).finally(() => {
if (timerId) clearTimeout(timerId);
});
}
/**
* Get plugin URLs with element and module definitions.
*/
getAndImportPlugins(name: string, options?: Options) {
return Promise.all(
this.getPlugins(name, options).map(pluginUrl => {
if (this._importedUrls.has(pluginUrl.href)) {
return Promise.resolve();
}
// TODO: we will deprecate html plugins entirely
// for now, keep the original behavior and import
// only for html ones
if (pluginUrl?.pathname.endsWith('.html')) {
return this.importUrl(pluginUrl);
} else {
return Promise.resolve();
}
})
);
}
}
// TODO(dmfilippov): Convert to service and add to appContext
let pluginEndpoints = new GrPluginEndpoints();
// To avoid mutable-exports, we don't want to export above variable directly
export function getPluginEndpoints() {
return pluginEndpoints;
}
export function _testOnly_resetEndpoints() {
pluginEndpoints = new GrPluginEndpoints();
}