| /** | |
| * @license | |
| * Copyright 2019 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { | |
| getPluginNameFromUrl, | |
| isThemeFile, | |
| PLUGIN_LOADING_TIMEOUT_MS, | |
| THEME_JS, | |
| } from './gr-api-utils'; | |
| import {Plugin} from './gr-public-js-api'; | |
| import {getBaseUrl} from '../../../utils/url-util'; | |
| import {GrPluginEndpoints} from './gr-plugin-endpoints'; | |
| import {PluginApi} from '../../../api/plugin'; | |
| import {ReportingService} from '../../../services/gr-reporting/gr-reporting'; | |
| import {fireAlert} from '../../../utils/event-util'; | |
| import {JsApiService} from './gr-js-api-types'; | |
| import {RestApiService} from '../../../services/gr-rest-api/gr-rest-api'; | |
| import {PluginsModel} from '../../../models/plugins/plugins-model'; | |
| import {FlowsModel} from '../../../models/flows/flows-model'; | |
| import {Gerrit} from '../../../api/gerrit'; | |
| import {fontStyles} from '../../../styles/gr-font-styles'; | |
| import {grFormStyles} from '../../../styles/gr-form-styles'; | |
| import {menuPageStyles} from '../../../styles/gr-menu-page-styles'; | |
| import {spinnerStyles} from '../../../styles/gr-spinner-styles'; | |
| import {subpageStyles} from '../../../styles/gr-subpage-styles'; | |
| import {tableStyles} from '../../../styles/gr-table-styles'; | |
| import {GrJsApiInterface} from './gr-js-api-interface-element'; | |
| import {define} from '../../../models/dependency'; | |
| import {modalStyles} from '../../../styles/gr-modal-styles'; | |
| import {Finalizable} from '../../../types/types'; | |
| import {materialStyles} from '../../../styles/gr-material-styles'; | |
| import {changeViewIntegrationStyles} from '../../../styles/gr-change-view-integration-shared-styles'; | |
| import {sharedStyles} from '../../../styles/shared-styles'; | |
| enum 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 failed to load. */ | |
| LOAD_FAILED = 'LOAD_FAILED', | |
| } | |
| interface PluginObject { | |
| name: string; | |
| url: string; | |
| state: PluginState; | |
| plugin: PluginApi | null; | |
| } | |
| interface PluginOption { | |
| sync?: boolean; | |
| } | |
| export interface PluginOptionMap { | |
| [path: string]: PluginOption; | |
| } | |
| type GerritScriptElement = HTMLScriptElement & { | |
| __importElement: HTMLScriptElement; | |
| }; | |
| // Prefix for any unrecognized plugin urls. | |
| // Url should match following patterns: | |
| // /plugins/PLUGINNAME/static/SCRIPTNAME.js | |
| // /plugins/PLUGINNAME.js | |
| const UNKNOWN_PLUGIN_PREFIX = '__$$__'; | |
| // Current API version for Plugin, | |
| // plugins with incompatible version will not be loaded. | |
| const API_VERSION = '0.1'; | |
| export const pluginLoaderToken = define<PluginLoader>('plugin-loader'); | |
| /** | |
| * 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 implements Gerrit, Finalizable { | |
| public readonly styles = { | |
| font: fontStyles, | |
| form: grFormStyles, | |
| material: materialStyles, | |
| menuPage: menuPageStyles, | |
| shared: sharedStyles, | |
| spinner: spinnerStyles, | |
| subPage: subpageStyles, | |
| table: tableStyles, | |
| modal: modalStyles, | |
| changeViewIntegration: changeViewIntegrationStyles, | |
| }; | |
| private pluginListLoaded = false; | |
| private plugins = new Map<string, PluginObject>(); | |
| // Promise that resolves when all plugins loaded | |
| private loadingPromise: Promise<void> | null = null; | |
| // Resolver to resolve loadingPromise once all plugins loaded | |
| private loadingResolver: (() => void) | null = null; | |
| private instanceId?: string; | |
| public readonly jsApiService: JsApiService; | |
| public readonly pluginsModel: PluginsModel; | |
| public pluginEndPoints: GrPluginEndpoints; | |
| constructor( | |
| private readonly reportingService: ReportingService, | |
| private readonly restApiService: RestApiService, | |
| private readonly getFlowsModel?: () => FlowsModel | |
| ) { | |
| this.jsApiService = new GrJsApiInterface( | |
| () => this.awaitPluginsLoaded(), | |
| this.reportingService | |
| ); | |
| this.pluginsModel = new PluginsModel(); | |
| this.awaitPluginsLoaded().finally(() => { | |
| this.pluginsModel.updateState({pluginsLoaded: true}); | |
| }); | |
| this.pluginEndPoints = new GrPluginEndpoints(); | |
| } | |
| finalize() {} | |
| /** | |
| * Use the plugin name or use the full url if not recognized. | |
| */ | |
| private getPluginKeyFromUrl(url: string) { | |
| return getPluginNameFromUrl(url) || `${UNKNOWN_PLUGIN_PREFIX}${url}`; | |
| } | |
| /** | |
| * Load multiple plugins with certain options. | |
| */ | |
| loadPlugins(plugins: string[] = [], instanceId?: string) { | |
| this.instanceId = instanceId; | |
| this.pluginListLoaded = true; | |
| plugins.forEach(path => { | |
| const url = this.urlFor(path, window.ASSETS_PATH); | |
| 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, '.js')) { | |
| this.loadJsPlugin(path); | |
| } else { | |
| this.failToLoad(`Unrecognized plugin path ${path}`, path); | |
| } | |
| }); | |
| this.awaitPluginsLoaded().then(() => { | |
| const loaded = this.getPluginsByState(PluginState.LOADED); | |
| const failed = this.getPluginsByState(PluginState.LOAD_FAILED); | |
| this.reportingService.pluginsLoaded(loaded.map(p => p.name)); | |
| this.reportingService.pluginsFailed(failed.map(p => p.name)); | |
| }); | |
| } | |
| private isPathEndsWith(url: string | URL, suffix: string) { | |
| if (!(url instanceof URL)) { | |
| try { | |
| url = new URL(url); | |
| } catch (e: unknown) { | |
| this.reportingService.error( | |
| 'GrPluginLoader', | |
| new Error('url parse error'), | |
| e | |
| ); | |
| return false; | |
| } | |
| } | |
| return url.pathname && url.pathname.endsWith(suffix); | |
| } | |
| private getPluginsByState(state: PluginState) { | |
| return [...this.plugins.values()].filter(p => p.state === state); | |
| } | |
| install( | |
| callback: (plugin: PluginApi) => void, | |
| version?: string, | |
| src?: string | |
| ) { | |
| // HTML import polyfill adds __importElement pointing to the import tag. | |
| const gerritScript = document.currentScript as GerritScriptElement | null; | |
| const script = gerritScript?.__importElement ?? gerritScript; | |
| if (!src && script && script.src) { | |
| src = script.src; | |
| } | |
| if ((!src || src.startsWith('data:')) && script && script.baseURI) { | |
| src = script && script.baseURI; | |
| } | |
| if (!src) { | |
| this.failToLoad('Failed to determine src.'); | |
| return; | |
| } | |
| if (version && version !== API_VERSION) { | |
| this.failToLoad( | |
| `Plugin ${src} install error: only version ${API_VERSION} is supported in PolyGerrit. ${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, | |
| this.jsApiService, | |
| this.reportingService, | |
| this.restApiService, | |
| this.pluginsModel, | |
| this.pluginEndPoints, | |
| this.getFlowsModel | |
| ); | |
| } | |
| try { | |
| callback(plugin); | |
| this.pluginInstalled(url, plugin); | |
| } catch (e: unknown) { | |
| if (e instanceof Error) { | |
| this.failToLoad(`${e.name}: ${e.message}`, src); | |
| } else { | |
| this.reportingService.error( | |
| 'GrPluginLoader', | |
| new Error('plugin callback error'), | |
| e | |
| ); | |
| } | |
| } | |
| } | |
| arePluginsLoaded() { | |
| if (!this.pluginListLoaded) return false; | |
| return this.getPluginsByState(PluginState.PENDING).length === 0; | |
| } | |
| private checkIfCompleted() { | |
| if (this.arePluginsLoaded()) { | |
| this.pluginEndPoints.setPluginsReady(); | |
| if (this.loadingResolver) { | |
| this.loadingResolver(); | |
| this.loadingResolver = null; | |
| this.loadingPromise = null; | |
| } | |
| } | |
| } | |
| private timeout() { | |
| const pending = this.getPluginsByState(PluginState.PENDING); | |
| for (const plugin of pending) { | |
| this.updatePluginState(plugin.url, PluginState.LOAD_FAILED); | |
| } | |
| this.checkIfCompleted(); | |
| const errorMessage = `Timeout when loading plugins: ${pending | |
| .map(p => p.name) | |
| .join(',')}`; | |
| fireAlert(document, errorMessage); | |
| return errorMessage; | |
| } | |
| // Private but mocked in tests. | |
| failToLoad(message: string, pluginUrl?: string) { | |
| // Show an alert with the error | |
| fireAlert(document, `Plugin install error: ${message} from ${pluginUrl}`); | |
| if (pluginUrl) this.updatePluginState(pluginUrl, PluginState.LOAD_FAILED); | |
| this.checkIfCompleted(); | |
| } | |
| private updatePluginState( | |
| pluginUrl: string, | |
| state: PluginState | |
| ): PluginObject { | |
| 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.info(`Plugin loaded separately: ${pluginUrl}`); | |
| this.plugins.set(key, { | |
| name: key, | |
| url: pluginUrl, | |
| state, | |
| plugin: null, | |
| }); | |
| } | |
| console.debug(`Plugin ${key} ${state}`); | |
| return this.plugins.get(key)!; | |
| } | |
| private pluginInstalled(url: string, plugin: PluginApi) { | |
| const pluginObj = this.updatePluginState(url, PluginState.LOADED); | |
| pluginObj.plugin = plugin; | |
| this.reportingService.pluginLoaded(plugin.getPluginName() || url); | |
| this.checkIfCompleted(); | |
| } | |
| /** | |
| * Checks if given plugin path/url is enabled or not. | |
| */ | |
| isPluginEnabled(pathOrUrl: string) { | |
| const url = this.urlFor(pathOrUrl); | |
| const key = this.getPluginKeyFromUrl(url); | |
| return this.plugins.has(key); | |
| } | |
| /** | |
| * Returns the plugin object with a given url. | |
| */ | |
| getPlugin(pathOrUrl: string) { | |
| const url = this.urlFor(pathOrUrl); | |
| const key = this.getPluginKeyFromUrl(url); | |
| return this.plugins.get(key); | |
| } | |
| /** | |
| * Checks if given plugin path/url is loaded or not. | |
| */ | |
| isPluginLoaded(pathOrUrl: string): boolean { | |
| const url = this.urlFor(pathOrUrl); | |
| const key = this.getPluginKeyFromUrl(url); | |
| return this.plugins.has(key) | |
| ? this.plugins.get(key)!.state === PluginState.LOADED | |
| : false; | |
| } | |
| // Private but mocked in tests. | |
| loadJsPlugin(pluginUrl: string) { | |
| const urlWithAP = this.urlFor(pluginUrl, window.ASSETS_PATH); | |
| const urlWithoutAP = this.urlFor(pluginUrl); | |
| let onerror = undefined; | |
| if (urlWithAP !== urlWithoutAP) { | |
| onerror = () => this.createScriptTag(urlWithoutAP); | |
| } | |
| this.createScriptTag(urlWithAP, onerror); | |
| } | |
| // Private but mocked in tests. | |
| createScriptTag(url: string, onerror?: OnErrorEventHandler) { | |
| 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); | |
| } | |
| private urlFor(pathOrUrl: string, assetsPath?: string): string { | |
| if (isThemeFile(pathOrUrl)) { | |
| if (assetsPath && this.instanceId) { | |
| return `${assetsPath}/hosts/${this.instanceId}${THEME_JS}`; | |
| } | |
| return window.location.origin + getBaseUrl() + THEME_JS; | |
| } | |
| if (pathOrUrl.startsWith('http')) { | |
| // Plugins are loaded from another domain or preloaded. | |
| if (pathOrUrl.includes(location.host) && assetsPath) { | |
| // 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 (assetsPath) { | |
| return assetsPath + pathOrUrl; | |
| } | |
| return window.location.origin + getBaseUrl() + pathOrUrl; | |
| } | |
| awaitPluginsLoaded() { | |
| // Resolve if completed. | |
| this.checkIfCompleted(); | |
| if (this.arePluginsLoaded()) { | |
| return Promise.resolve(); | |
| } | |
| if (!this.loadingPromise) { | |
| // specify window here so that TS pulls the correct setTimeout method | |
| // if window is not specified, then the function is pulled from node | |
| // and the return type is NodeJS.Timeout object | |
| let timerId: number; | |
| this.loadingPromise = Promise.race([ | |
| new Promise<void>(resolve => (this.loadingResolver = resolve)), | |
| new Promise( | |
| (_, reject) => | |
| (timerId = window.setTimeout(() => { | |
| reject(new Error(this.timeout())); | |
| }, PLUGIN_LOADING_TIMEOUT_MS)) | |
| ), | |
| ]).finally(() => { | |
| if (timerId) clearTimeout(timerId); | |
| }) as Promise<void>; | |
| } | |
| return this.loadingPromise; | |
| } | |
| } |