blob: 2f2730486c1d7699af23f1ad2823948789735455 [file] [log] [blame]
/**
* @license
* Copyright (C) 2019 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 './gr-api-utils.js';
import {
PLUGIN_LOADING_TIMEOUT_MS,
PRELOADED_PROTOCOL,
getPluginNameFromUrl,
getBaseUrl,
} from './gr-api-utils.js';
/**
* @enum {string}
*/
const PluginState = {
/**
* State that indicates the plugin is pending to be loaded.
*/
PENDING: 'PENDING',
/**
* State that indicates the plugin is already loaded.
*/
LOADED: 'LOADED',
/**
* State that indicates the plugin is already loaded.
*/
PRE_LOADED: 'PRE_LOADED',
/**
* State that indicates the plugin failed to load.
*/
LOAD_FAILED: 'LOAD_FAILED',
};
// Prefix for any unrecognized plugin urls.
// Url should match following patterns:
// /plugins/PLUGINNAME/static/SCRIPTNAME.(html|js)
// /plugins/PLUGINNAME.(js|html)
const UNKNOWN_PLUGIN_PREFIX = '__$$__';
// Current API version for Plugin,
// plugins with incompatible version will not be laoded.
const API_VERSION = '0.1';
/**
* PluginLoader, responsible for:
*
* Loading all plugins and handling errors etc.
* Recording plugin state.
* Reporting on plugin loading status.
* Retrieve plugin.
* Check plugin status and if all plugins loaded.
*/
export class PluginLoader {
constructor() {
this._pluginListLoaded = false;
/** @type {Map<string,PluginLoader.PluginObject>} */
this._plugins = new Map();
this._reporting = null;
// Promise that resolves when all plugins loaded
this._loadingPromise = null;
// Resolver to resolve _loadingPromise once all plugins loaded
this._loadingResolver = null;
}
_getReporting() {
if (!this._reporting) {
this._reporting = document.createElement('gr-reporting');
}
return this._reporting;
}
/**
* Use the plugin name or use the full url if not recognized.
*
* @see gr-api-utils#getPluginNameFromUrl
* @param {string|URL} url
*/
_getPluginKeyFromUrl(url) {
return getPluginNameFromUrl(url) ||
`${UNKNOWN_PLUGIN_PREFIX}${url}`;
}
/**
* Load multiple plugins with certain options.
*
* @param {Array<string>} plugins
* @param {Object<string, PluginLoader.PluginOption>} opts
*/
loadPlugins(plugins = [], opts = {}) {
this._pluginListLoaded = true;
plugins.forEach(path => {
const url = this._urlFor(path, window.ASSETS_PATH);
// Skip if preloaded, for bundling.
if (this.isPluginPreloaded(url)) return;
const pluginKey = this._getPluginKeyFromUrl(url);
// Skip if already installed.
if (this._plugins.has(pluginKey)) return;
this._plugins.set(pluginKey, {
name: pluginKey,
url,
state: PluginState.PENDING,
plugin: null,
});
if (this._isPathEndsWith(url, '.html')) {
this._importHtmlPlugin(path, opts && opts[path]);
} else if (this._isPathEndsWith(url, '.js')) {
this._loadJsPlugin(path);
} else {
this._failToLoad(`Unrecognized plugin path ${path}`, path);
}
});
this.awaitPluginsLoaded().then(() => {
console.info('Plugins loaded');
this._getReporting().pluginsLoaded(this._getAllInstalledPluginNames());
});
}
_isPathEndsWith(url, suffix) {
if (!(url instanceof URL)) {
try {
url = new URL(url);
} catch (e) {
console.warn(e);
return false;
}
}
return url.pathname && url.pathname.endsWith(suffix);
}
_getAllInstalledPluginNames() {
const installedPlugins = [];
for (const plugin of this._plugins.values()) {
if (plugin.state === PluginState.LOADED) {
installedPlugins.push(plugin.name);
}
}
return installedPlugins;
}
install(callback, opt_version, opt_src) {
// HTML import polyfill adds __importElement pointing to the import tag.
const script = document.currentScript &&
(document.currentScript.__importElement || document.currentScript);
let src = opt_src || (script && script.src);
if (!src || src.startsWith('data:')) {
src = script && script.baseURI;
}
if (opt_version && opt_version !== API_VERSION) {
this._failToLoad(`Plugin ${src} install error: only version ` +
API_VERSION + ' is supported in PolyGerrit. ' + opt_version +
' was given.', src);
return;
}
const url = this._urlFor(src);
const pluginObject = this.getPlugin(url);
let plugin = pluginObject && pluginObject.plugin;
if (!plugin) {
plugin = new Plugin(url);
}
try {
callback(plugin);
this._pluginInstalled(url, plugin);
} catch (e) {
this._failToLoad(`${e.name}: ${e.message}`, src);
}
}
// The polygerrit uses version of sinon where you can't stub getter,
// declare it as a function here
arePluginsLoaded() {
// As the size of plugins is relatively small,
// so the performance of this check should be reasonable
if (!this._pluginListLoaded) return false;
for (const plugin of this._plugins.values()) {
if (plugin.state === PluginState.PENDING) return false;
}
return true;
}
_checkIfCompleted() {
if (this.arePluginsLoaded() && this._loadingResolver) {
this._loadingResolver();
this._loadingResolver = null;
this._loadingPromise = null;
}
}
_timeout() {
const pendingPlugins = [];
for (const plugin of this._plugins.values()) {
if (plugin.state === PluginState.PENDING) {
this._updatePluginState(plugin.url, PluginState.LOAD_FAILED);
this._checkIfCompleted();
pendingPlugins.push(plugin.url);
}
}
return `Timeout when loading plugins: ${pendingPlugins.join(',')}`;
}
_failToLoad(message, pluginUrl) {
// Show an alert with the error
document.dispatchEvent(new CustomEvent('show-alert', {
detail: {
message: `Plugin install error: ${message} from ${pluginUrl}`,
},
}));
this._updatePluginState(pluginUrl, PluginState.LOAD_FAILED);
this._checkIfCompleted();
}
_updatePluginState(pluginUrl, state) {
const key = this._getPluginKeyFromUrl(pluginUrl);
if (this._plugins.has(key)) {
this._plugins.get(key).state = state;
} else {
// Plugin is not recorded for some reason.
console.warn(`Plugin loaded separately: ${pluginUrl}`);
this._plugins.set(key, {
name: key,
url: pluginUrl,
state,
plugin: null,
});
}
return this._plugins.get(key);
}
_pluginInstalled(url, plugin) {
const pluginObj = this._updatePluginState(url, PluginState.LOADED);
pluginObj.plugin = plugin;
this._getReporting().pluginLoaded(plugin.getPluginName() || url);
console.log(`Plugin ${plugin.getPluginName() || url} installed.`);
this._checkIfCompleted();
}
installPreloadedPlugins() {
if (!window.Gerrit || !window.Gerrit._preloadedPlugins) { return; }
const Gerrit = window.Gerrit;
for (const name in Gerrit._preloadedPlugins) {
if (!Gerrit._preloadedPlugins.hasOwnProperty(name)) { continue; }
const callback = Gerrit._preloadedPlugins[name];
this.install(callback, API_VERSION, PRELOADED_PROTOCOL + name);
}
}
isPluginPreloaded(pathOrUrl) {
const url = this._urlFor(pathOrUrl);
const name = getPluginNameFromUrl(url);
if (name && window.Gerrit._preloadedPlugins) {
return window.Gerrit._preloadedPlugins.hasOwnProperty(name);
} else {
return false;
}
}
/**
* Checks if given plugin path/url is enabled or not.
*
* @param {string} pathOrUrl
*/
isPluginEnabled(pathOrUrl) {
const url = this._urlFor(pathOrUrl);
if (this.isPluginPreloaded(url)) return true;
const key = this._getPluginKeyFromUrl(url);
return this._plugins.has(key);
}
/**
* Returns the plugin object with a given url.
*
* @param {string} pathOrUrl
*/
getPlugin(pathOrUrl) {
const key = this._getPluginKeyFromUrl(this._urlFor(pathOrUrl));
return this._plugins.get(key);
}
/**
* Checks if given plugin path/url is loaded or not.
*
* @param {string} pathOrUrl
*/
isPluginLoaded(pathOrUrl) {
const url = this._urlFor(pathOrUrl);
const key = this._getPluginKeyFromUrl(url);
return this._plugins.has(key) ?
this._plugins.get(key).state === PluginState.LOADED :
false;
}
_importHtmlPlugin(pluginUrl, opts = {}) {
const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
const urlWithoutAP = this._urlFor(pluginUrl);
let onerror = null;
if (urlWithAP !== urlWithoutAP) {
onerror = () => this._loadHtmlPlugin(urlWithoutAP, opts.sync);
}
this._loadHtmlPlugin(urlWithAP, opts.sync, onerror);
}
_loadHtmlPlugin(url, sync, onerror) {
if (!onerror) {
onerror = () => {
this._failToLoad(`${url} import error`, url);
};
}
(Polymer.importHref || Polymer.Base.importHref)(
url, () => {},
onerror,
!sync);
}
_loadJsPlugin(pluginUrl) {
const urlWithAP = this._urlFor(pluginUrl, window.ASSETS_PATH);
const urlWithoutAP = this._urlFor(pluginUrl);
let onerror = null;
if (urlWithAP !== urlWithoutAP) {
onerror = () => this._createScriptTag(urlWithoutAP);
}
this._createScriptTag(urlWithAP, onerror);
}
_createScriptTag(url, onerror) {
if (!onerror) {
onerror = () => this._failToLoad(`${url} load error`, url);
}
const el = document.createElement('script');
el.defer = true;
el.setAttribute('src', url);
// no credentials to send when fetch plugin js
// and this will help provide more meaningful error than
// 'Script error.'
el.setAttribute('crossorigin', 'anonymous');
el.onerror = onerror;
return document.body.appendChild(el);
}
_urlFor(pathOrUrl, assetsPath) {
if (!pathOrUrl) {
return pathOrUrl;
}
// theme is per host, should always load from assetsPath
const isThemeFile = pathOrUrl.endsWith('static/gerrit-theme.html');
const shouldTryLoadFromAssetsPathFirst = !isThemeFile && assetsPath;
if (pathOrUrl.startsWith(PRELOADED_PROTOCOL) ||
pathOrUrl.startsWith('http')) {
// Plugins are loaded from another domain or preloaded.
if (pathOrUrl.includes(location.host) &&
shouldTryLoadFromAssetsPathFirst) {
// if is loading from host server, try replace with cdn when assetsPath provided
return pathOrUrl
.replace(location.origin, assetsPath);
}
return pathOrUrl;
}
if (!pathOrUrl.startsWith('/')) {
pathOrUrl = '/' + pathOrUrl;
}
if (shouldTryLoadFromAssetsPathFirst) {
return assetsPath + pathOrUrl;
}
return window.location.origin + getBaseUrl() + pathOrUrl;
}
awaitPluginsLoaded() {
// Resolve if completed.
this._checkIfCompleted();
if (this.arePluginsLoaded()) {
return Promise.resolve();
}
if (!this._loadingPromise) {
let timerId;
this._loadingPromise =
Promise.race([
new Promise(resolve => this._loadingResolver = resolve),
new Promise((_, reject) => timerId = setTimeout(
() => {
reject(new Error(this._timeout()));
}, PLUGIN_LOADING_TIMEOUT_MS)),
]).then(() => {
if (timerId) clearTimeout(timerId);
});
}
return this._loadingPromise;
}
}
/**
* @typedef {{
* name:string,
* url:string,
* state:PluginState,
* plugin:Object
* }}
*/
PluginLoader.PluginObject;
/**
* @typedef {{
* sync:boolean,
* }}
*/
PluginLoader.PluginOption;
// TODO(dmfilippov): Convert to service and add to appContext
export let pluginLoader = new PluginLoader();
export function _testOnly_resetPluginLoader() {
pluginLoader = new PluginLoader();
}