diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts
index 9d9620e3b0853e..677bb32650e0de 100644
--- a/src/core/public/core_system.test.ts
+++ b/src/core/public/core_system.test.ts
@@ -17,6 +17,9 @@
* under the License.
*/
+import { Observable } from 'rxjs';
+import { take } from 'rxjs/operators';
+
import { basePathServiceMock } from './base_path/base_path_service.mock';
import { chromeServiceMock } from './chrome/chrome_service.mock';
import { fatalErrorsServiceMock } from './fatal_errors/fatal_errors_service.mock';
@@ -25,6 +28,7 @@ import { i18nServiceMock } from './i18n/i18n_service.mock';
import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock';
import { legacyPlatformServiceMock } from './legacy/legacy_service.mock';
import { notificationServiceMock } from './notifications/notifications_service.mock';
+import { pluginsServiceMock } from './plugins/plugins_service.mock';
import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock';
const MockLegacyPlatformService = legacyPlatformServiceMock.create();
@@ -85,6 +89,12 @@ jest.mock('./chrome', () => ({
ChromeService: ChromeServiceConstructor,
}));
+const MockPluginsService = pluginsServiceMock.create();
+const PluginsServiceConstructor = jest.fn().mockImplementation(() => MockPluginsService);
+jest.mock('./plugins', () => ({
+ PluginsService: PluginsServiceConstructor,
+}));
+
import { CoreSystem } from './core_system';
jest.spyOn(CoreSystem.prototype, 'stop');
@@ -160,7 +170,7 @@ describe('constructor', () => {
expect(NotificationServiceConstructor).toHaveBeenCalledTimes(1);
expect(NotificationServiceConstructor).toHaveBeenCalledWith({
- targetDomElement: expect.any(HTMLElement),
+ targetDomElement$: expect.any(Observable),
});
});
@@ -244,15 +254,15 @@ describe('#stop', () => {
expect(MockI18nService.stop).toHaveBeenCalled();
});
- it('clears the rootDomElement', () => {
+ it('clears the rootDomElement', async () => {
const rootDomElement = document.createElement('div');
const coreSystem = createCoreSystem({
rootDomElement,
});
- coreSystem.setup();
+ await coreSystem.setup();
expect(rootDomElement.innerHTML).not.toBe('');
- coreSystem.stop();
+ await coreSystem.stop();
expect(rootDomElement.innerHTML).toBe('');
});
});
@@ -260,63 +270,68 @@ describe('#stop', () => {
describe('#setup()', () => {
function setupCore(rootDomElement = defaultCoreSystemParams.rootDomElement) {
const core = createCoreSystem({
+ ...defaultCoreSystemParams,
rootDomElement,
});
return core.setup();
}
- it('clears the children of the rootDomElement and appends container for legacyPlatform and notifications', () => {
+ it('clears the children of the rootDomElement and appends container for legacyPlatform and notifications', async () => {
const root = document.createElement('div');
root.innerHTML = '
foo bar
';
- setupCore(root);
+ await setupCore(root);
expect(root.innerHTML).toBe('');
});
- it('calls injectedMetadata#setup()', () => {
- setupCore();
-
+ it('calls injectedMetadata#setup()', async () => {
+ await setupCore();
expect(MockInjectedMetadataService.setup).toHaveBeenCalledTimes(1);
});
- it('calls http#setup()', () => {
- setupCore();
+ it('calls http#setup()', async () => {
+ await setupCore();
expect(MockHttpService.setup).toHaveBeenCalledTimes(1);
});
- it('calls basePath#setup()', () => {
- setupCore();
+ it('calls basePath#setup()', async () => {
+ await setupCore();
expect(MockBasePathService.setup).toHaveBeenCalledTimes(1);
});
- it('calls uiSettings#setup()', () => {
- setupCore();
+ it('calls uiSettings#setup()', async () => {
+ await setupCore();
expect(MockUiSettingsService.setup).toHaveBeenCalledTimes(1);
});
- it('calls i18n#setup()', () => {
- setupCore();
+ it('calls i18n#setup()', async () => {
+ await setupCore();
expect(MockI18nService.setup).toHaveBeenCalledTimes(1);
});
- it('calls fatalErrors#setup()', () => {
- setupCore();
+ it('calls fatalErrors#setup()', async () => {
+ await setupCore();
expect(MockFatalErrorsService.setup).toHaveBeenCalledTimes(1);
});
- it('calls notifications#setup()', () => {
- setupCore();
+ it('calls notifications#setup()', async () => {
+ await setupCore();
expect(MockNotificationsService.setup).toHaveBeenCalledTimes(1);
});
- it('calls chrome#setup()', () => {
- setupCore();
+ it('calls chrome#setup()', async () => {
+ await setupCore();
expect(MockChromeService.setup).toHaveBeenCalledTimes(1);
});
+
+ it('calls plugin#setup()', async () => {
+ await setupCore();
+ expect(MockPluginsService.setup).toHaveBeenCalledTimes(1);
+ });
});
describe('LegacyPlatform targetDomElement', () => {
- it('only mounts the element when set up, before setting up the legacyPlatformService', () => {
+ it('only mounts the element when set up, before setting up the legacyPlatformService', async () => {
const rootDomElement = document.createElement('div');
const core = createCoreSystem({
rootDomElement,
@@ -332,32 +347,35 @@ describe('LegacyPlatform targetDomElement', () => {
expect(targetDomElement).toHaveProperty('parentElement', null);
// setting up the core system should mount the targetDomElement as a child of the rootDomElement
- core.setup();
+ await core.setup();
expect(targetDomElementParentInSetup!).toBe(rootDomElement);
});
});
describe('Notifications targetDomElement', () => {
- it('only mounts the element when set up, before setting up the notificationsService', () => {
+ it('only mounts the element when set up, before setting up the notificationsService', async () => {
const rootDomElement = document.createElement('div');
const core = createCoreSystem({
rootDomElement,
});
- let targetDomElementParentInSetup: HTMLElement;
+ const [[{ targetDomElement$ }]] = NotificationServiceConstructor.mock.calls;
+ let targetDomElementParentInSetup: HTMLElement | null;
MockNotificationsService.setup.mockImplementation(
(): any => {
- targetDomElementParentInSetup = targetDomElement.parentElement;
+ (targetDomElement$ as Observable).pipe(take(1)).subscribe({
+ next: targetDomElement => {
+ // The targetDomElement should already be a child once it's received by the NotificationsService
+ expect(targetDomElement.parentElement).not.toBeNull();
+ targetDomElementParentInSetup = targetDomElement.parentElement;
+ },
+ });
}
);
- // targetDomElement should not have a parent element when the LegacyPlatformService is constructed
- const [[{ targetDomElement }]] = NotificationServiceConstructor.mock.calls;
- expect(targetDomElement).toHaveProperty('parentElement', null);
-
// setting up the core system should mount the targetDomElement as a child of the rootDomElement
- core.setup();
+ await core.setup();
expect(targetDomElementParentInSetup!).toBe(rootDomElement);
});
});
diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts
index 11a00fd66c4c4b..0a8b93b6581569 100644
--- a/src/core/public/core_system.ts
+++ b/src/core/public/core_system.ts
@@ -19,6 +19,9 @@
import './core.css';
+import { Subject } from 'rxjs';
+
+import { CoreSetup } from '.';
import { BasePathService } from './base_path';
import { ChromeService } from './chrome';
import { FatalErrorsService } from './fatal_errors';
@@ -27,6 +30,7 @@ import { I18nService } from './i18n';
import { InjectedMetadataParams, InjectedMetadataService } from './injected_metadata';
import { LegacyPlatformParams, LegacyPlatformService } from './legacy';
import { NotificationsService } from './notifications';
+import { PluginsService } from './plugins';
import { UiSettingsService } from './ui_settings';
interface Params {
@@ -37,6 +41,10 @@ interface Params {
useLegacyTestHarness?: LegacyPlatformParams['useLegacyTestHarness'];
}
+/** @internal */
+// tslint:disable-next-line no-empty-interface
+export interface CoreContext {}
+
/**
* The CoreSystem is the root of the new platform, and setups all parts
* of Kibana in the UI, including the LegacyPlatform which is managed
@@ -55,9 +63,10 @@ export class CoreSystem {
private readonly basePath: BasePathService;
private readonly chrome: ChromeService;
private readonly i18n: I18nService;
+ private readonly plugins: PluginsService;
private readonly rootDomElement: HTMLElement;
- private readonly notificationsTargetDomElement: HTMLDivElement;
+ private readonly notificationsTargetDomElement$: Subject;
private readonly legacyPlatformTargetDomElement: HTMLDivElement;
constructor(params: Params) {
@@ -85,15 +94,18 @@ export class CoreSystem {
},
});
- this.notificationsTargetDomElement = document.createElement('div');
+ this.notificationsTargetDomElement$ = new Subject();
this.notifications = new NotificationsService({
- targetDomElement: this.notificationsTargetDomElement,
+ targetDomElement$: this.notificationsTargetDomElement$.asObservable(),
});
this.http = new HttpService();
this.basePath = new BasePathService();
this.uiSettings = new UiSettingsService();
this.chrome = new ChromeService({ browserSupportsCsp });
+ const core: CoreContext = {};
+ this.plugins = new PluginsService(core);
+
this.legacyPlatformTargetDomElement = document.createElement('div');
this.legacyPlatform = new LegacyPlatformService({
targetDomElement: this.legacyPlatformTargetDomElement,
@@ -102,14 +114,8 @@ export class CoreSystem {
});
}
- public setup() {
+ public async setup() {
try {
- // ensure the rootDomElement is empty
- this.rootDomElement.textContent = '';
- this.rootDomElement.classList.add('coreSystemRootDomElement');
- this.rootDomElement.appendChild(this.notificationsTargetDomElement);
- this.rootDomElement.appendChild(this.legacyPlatformTargetDomElement);
-
const i18n = this.i18n.setup();
const notifications = this.notifications.setup({ i18n });
const injectedMetadata = this.injectedMetadata.setup();
@@ -127,16 +133,32 @@ export class CoreSystem {
notifications,
});
- this.legacyPlatform.setup({
+ const core: CoreSetup = {
+ basePath,
+ chrome,
+ fatalErrors,
+ http,
i18n,
injectedMetadata,
- fatalErrors,
notifications,
- http,
- basePath,
uiSettings,
- chrome,
- });
+ };
+
+ await this.plugins.setup(core);
+
+ // ensure the rootDomElement is empty
+ this.rootDomElement.textContent = '';
+ this.rootDomElement.classList.add('coreSystemRootDomElement');
+
+ const notificationsTargetDomElement = document.createElement('div');
+ this.rootDomElement.appendChild(notificationsTargetDomElement);
+ this.rootDomElement.appendChild(this.legacyPlatformTargetDomElement);
+
+ // Only provide the DOM element to notifications once it's attached to the page.
+ // This prevents notifications from timing out before being displayed.
+ this.notificationsTargetDomElement$.next(notificationsTargetDomElement);
+
+ this.legacyPlatform.setup(core);
return { fatalErrors };
} catch (error) {
@@ -146,6 +168,7 @@ export class CoreSystem {
public stop() {
this.legacyPlatform.stop();
+ this.plugins.stop();
this.notifications.stop();
this.http.stop();
this.uiSettings.stop();
diff --git a/src/core/public/index.ts b/src/core/public/index.ts
index 57fea257206508..e29861988a5a13 100644
--- a/src/core/public/index.ts
+++ b/src/core/public/index.ts
@@ -26,7 +26,7 @@ import { InjectedMetadataSetup } from './injected_metadata';
import { NotificationsSetup } from './notifications';
import { UiSettingsSetup } from './ui_settings';
-export { CoreSystem } from './core_system';
+export { CoreContext, CoreSystem } from './core_system';
export interface CoreSetup {
i18n: I18nSetup;
@@ -38,3 +38,5 @@ export interface CoreSetup {
uiSettings: UiSettingsSetup;
chrome: ChromeSetup;
}
+
+export { PluginInitializer, PluginInitializerContext, PluginSetupContext } from './plugins';
diff --git a/src/core/public/notifications/notifications_service.ts b/src/core/public/notifications/notifications_service.ts
index 3e36ca0552c387..d9349a8cc540a6 100644
--- a/src/core/public/notifications/notifications_service.ts
+++ b/src/core/public/notifications/notifications_service.ts
@@ -17,11 +17,12 @@
* under the License.
*/
+import { Observable, Subject, Subscription } from 'rxjs';
import { I18nSetup } from '../i18n';
import { ToastsService } from './toasts';
interface Params {
- targetDomElement: HTMLElement;
+ targetDomElement$: Observable;
}
interface Deps {
@@ -31,27 +32,45 @@ interface Deps {
export class NotificationsService {
private readonly toasts: ToastsService;
- private readonly toastsContainer: HTMLElement;
+ private readonly toastsContainer$: Subject;
+ private domElemSubscription?: Subscription;
+ private targetDomElement?: HTMLElement;
constructor(private readonly params: Params) {
- this.toastsContainer = document.createElement('div');
+ this.toastsContainer$ = new Subject();
this.toasts = new ToastsService({
- targetDomElement: this.toastsContainer,
+ targetDomElement$: this.toastsContainer$.asObservable(),
});
}
public setup({ i18n }: Deps) {
- this.params.targetDomElement.appendChild(this.toastsContainer);
+ this.domElemSubscription = this.params.targetDomElement$.subscribe({
+ next: targetDomElement => {
+ this.cleanupTargetDomElement();
+ this.targetDomElement = targetDomElement;
- return {
- toasts: this.toasts.setup({ i18n }),
- };
+ const toastsContainer = document.createElement('div');
+ targetDomElement.appendChild(toastsContainer);
+ this.toastsContainer$.next(toastsContainer);
+ },
+ });
+
+ return { toasts: this.toasts.setup({ i18n }) };
}
public stop() {
this.toasts.stop();
+ this.cleanupTargetDomElement();
+
+ if (this.domElemSubscription) {
+ this.domElemSubscription.unsubscribe();
+ }
+ }
- this.params.targetDomElement.textContent = '';
+ private cleanupTargetDomElement() {
+ if (this.targetDomElement) {
+ this.targetDomElement.textContent = '';
+ }
}
}
diff --git a/src/core/public/notifications/toasts/toasts_service.test.tsx b/src/core/public/notifications/toasts/toasts_service.test.tsx
index d380ad5b77ea6c..dd985b77235b66 100644
--- a/src/core/public/notifications/toasts/toasts_service.test.tsx
+++ b/src/core/public/notifications/toasts/toasts_service.test.tsx
@@ -24,6 +24,7 @@ jest.mock('react-dom', () => ({
unmountComponentAtNode: mockReactDomUnmount,
}));
+import { of } from 'rxjs';
import { ToastsService } from './toasts_service';
import { ToastsSetup } from './toasts_start';
@@ -37,7 +38,7 @@ describe('#setup()', () => {
it('renders the GlobalToastList into the targetDomElement param', async () => {
const targetDomElement = document.createElement('div');
targetDomElement.setAttribute('test', 'target-dom-element');
- const toasts = new ToastsService({ targetDomElement });
+ const toasts = new ToastsService({ targetDomElement$: of(targetDomElement) });
expect(mockReactDomRender).not.toHaveBeenCalled();
toasts.setup({ i18n: mockI18n });
@@ -46,7 +47,7 @@ describe('#setup()', () => {
it('returns a ToastsSetup', () => {
const toasts = new ToastsService({
- targetDomElement: document.createElement('div'),
+ targetDomElement$: of(document.createElement('div')),
});
expect(toasts.setup({ i18n: mockI18n })).toBeInstanceOf(ToastsSetup);
@@ -57,7 +58,7 @@ describe('#stop()', () => {
it('unmounts the GlobalToastList from the targetDomElement', () => {
const targetDomElement = document.createElement('div');
targetDomElement.setAttribute('test', 'target-dom-element');
- const toasts = new ToastsService({ targetDomElement });
+ const toasts = new ToastsService({ targetDomElement$: of(targetDomElement) });
toasts.setup({ i18n: mockI18n });
@@ -69,7 +70,7 @@ describe('#stop()', () => {
it('does not fail if setup() was never called', () => {
const targetDomElement = document.createElement('div');
targetDomElement.setAttribute('test', 'target-dom-element');
- const toasts = new ToastsService({ targetDomElement });
+ const toasts = new ToastsService({ targetDomElement$: of(targetDomElement) });
expect(() => {
toasts.stop();
}).not.toThrowError();
@@ -77,9 +78,9 @@ describe('#stop()', () => {
it('empties the content of the targetDomElement', () => {
const targetDomElement = document.createElement('div');
- const toasts = new ToastsService({ targetDomElement });
+ const toasts = new ToastsService({ targetDomElement$: of(targetDomElement) });
- targetDomElement.appendChild(document.createTextNode('foo bar'));
+ toasts.setup({ i18n: mockI18n });
toasts.stop();
expect(targetDomElement.childNodes).toHaveLength(0);
});
diff --git a/src/core/public/notifications/toasts/toasts_service.tsx b/src/core/public/notifications/toasts/toasts_service.tsx
index d4049adcefbad6..912d01689ac671 100644
--- a/src/core/public/notifications/toasts/toasts_service.tsx
+++ b/src/core/public/notifications/toasts/toasts_service.tsx
@@ -19,6 +19,7 @@
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
+import { Observable, Subscription } from 'rxjs';
import { Toast } from '@elastic/eui';
import { I18nSetup } from '../../i18n';
@@ -26,7 +27,7 @@ import { GlobalToastList } from './global_toast_list';
import { ToastsSetup } from './toasts_start';
interface Params {
- targetDomElement: HTMLElement;
+ targetDomElement$: Observable;
}
interface Deps {
@@ -34,27 +35,46 @@ interface Deps {
}
export class ToastsService {
+ private domElemSubscription?: Subscription;
+ private targetDomElement?: HTMLElement;
+
constructor(private readonly params: Params) {}
public setup({ i18n }: Deps) {
const toasts = new ToastsSetup();
- render(
-
- toasts.remove(toast)}
- toasts$={toasts.get$()}
- />
- ,
- this.params.targetDomElement
- );
+ this.domElemSubscription = this.params.targetDomElement$.subscribe({
+ next: targetDomElement => {
+ this.cleanupTargetDomElement();
+ this.targetDomElement = targetDomElement;
+
+ render(
+
+ toasts.remove(toast)}
+ toasts$={toasts.get$()}
+ />
+ ,
+ targetDomElement
+ );
+ },
+ });
return toasts;
}
public stop() {
- unmountComponentAtNode(this.params.targetDomElement);
+ this.cleanupTargetDomElement();
+
+ if (this.domElemSubscription) {
+ this.domElemSubscription.unsubscribe();
+ }
+ }
- this.params.targetDomElement.textContent = '';
+ private cleanupTargetDomElement() {
+ if (this.targetDomElement) {
+ unmountComponentAtNode(this.targetDomElement);
+ this.targetDomElement.textContent = '';
+ }
}
}
diff --git a/src/core/public/plugins/index.ts b/src/core/public/plugins/index.ts
new file mode 100644
index 00000000000000..ed1fe8fc660eec
--- /dev/null
+++ b/src/core/public/plugins/index.ts
@@ -0,0 +1,22 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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.
+ */
+
+export * from './plugins_service';
+export { PluginInitializer } from './plugin';
+export { PluginInitializerContext, PluginSetupContext } from './plugin_context';
diff --git a/src/core/public/plugins/plugin.test.ts b/src/core/public/plugins/plugin.test.ts
new file mode 100644
index 00000000000000..446ef22a4e9bf1
--- /dev/null
+++ b/src/core/public/plugins/plugin.test.ts
@@ -0,0 +1,109 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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.
+ */
+
+const mockPlugin = {
+ setup: jest.fn(),
+ stop: jest.fn(),
+};
+const mockInitializer = jest.fn(() => mockPlugin);
+const mockPluginLoader = jest.fn(() => Promise.resolve(mockInitializer));
+
+jest.mock('./plugin_loader', () => ({
+ loadPluginBundle: mockPluginLoader,
+}));
+
+import { DiscoveredPlugin } from '../../server';
+import { Plugin } from './plugin';
+
+function createManifest(
+ id: string,
+ { required = [], optional = [] }: { required?: string[]; optional?: string[]; ui?: boolean } = {}
+) {
+ return {
+ id,
+ version: 'some-version',
+ configPath: ['path'],
+ requiredPlugins: required,
+ optionalPlugins: optional,
+ } as DiscoveredPlugin;
+}
+
+let plugin: Plugin>;
+const initializerContext = {};
+const addBasePath = (path: string) => path;
+
+beforeEach(() => {
+ mockPluginLoader.mockClear();
+ mockPlugin.setup.mockClear();
+ mockPlugin.stop.mockClear();
+ plugin = new Plugin(createManifest('plugin-a'), initializerContext);
+});
+
+test('`load` calls loadPluginBundle', () => {
+ plugin.load(addBasePath);
+ expect(mockPluginLoader).toHaveBeenCalledWith(addBasePath, 'plugin-a');
+});
+
+test('`setup` fails if load is not called first', async () => {
+ await expect(plugin.setup({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Plugin \\"plugin-a\\" can't be setup since its bundle isn't loaded."`
+ );
+});
+
+test('`setup` fails if plugin.setup is not a function', async () => {
+ mockInitializer.mockReturnValueOnce({ stop: jest.fn() } as any);
+ await plugin.load(addBasePath);
+ await expect(plugin.setup({} as any, {} as any)).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Instance of plugin \\"plugin-a\\" does not define \\"setup\\" function."`
+ );
+});
+
+test('`setup` calls initializer with initializer context', async () => {
+ await plugin.load(addBasePath);
+ await plugin.setup({} as any, {} as any);
+ expect(mockInitializer).toHaveBeenCalledWith(initializerContext);
+});
+
+test('`setup` calls plugin.setup with context and dependencies', async () => {
+ await plugin.load(addBasePath);
+ const context = { any: 'thing' } as any;
+ const deps = { otherDep: 'value' };
+ await plugin.setup(context, deps);
+ expect(mockPlugin.setup).toHaveBeenCalledWith(context, deps);
+});
+
+test('`stop` fails if plugin is not setup up', async () => {
+ await expect(plugin.stop()).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Plugin \\"plugin-a\\" can't be stopped since it isn't set up."`
+ );
+});
+
+test('`stop` calls plugin.stop', async () => {
+ await plugin.load(addBasePath);
+ await plugin.setup({} as any, {} as any);
+ await plugin.stop();
+ expect(mockPlugin.stop).toHaveBeenCalled();
+});
+
+test('`stop` does not fail if plugin.stop does not exist', async () => {
+ mockInitializer.mockReturnValueOnce({ setup: jest.fn() } as any);
+ await plugin.load(addBasePath);
+ await plugin.setup({} as any, {} as any);
+ await expect(plugin.stop()).resolves.not.toThrow();
+});
diff --git a/src/core/public/plugins/plugin.ts b/src/core/public/plugins/plugin.ts
new file mode 100644
index 00000000000000..540ab65864088b
--- /dev/null
+++ b/src/core/public/plugins/plugin.ts
@@ -0,0 +1,115 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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 { DiscoveredPlugin, PluginName } from '../../server';
+import { PluginInitializerContext, PluginSetupContext } from './plugin_context';
+import { loadPluginBundle } from './plugin_loader';
+
+/**
+ * The `plugin` export at the root of a plugin's `public` directory should conform
+ * to this interface.
+ */
+export type PluginInitializer> = (
+ core: PluginInitializerContext
+) => {
+ setup: (core: PluginSetupContext, dependencies: TDependencies) => TSetup | Promise;
+ stop?: () => void | Promise;
+};
+
+/**
+ * Lightweight wrapper around discovered plugin that is responsible for instantiating
+ * plugin and dispatching proper context and dependencies into plugin's lifecycle hooks.
+ * @internal
+ */
+export class Plugin<
+ TSetup = unknown,
+ TDependenciesSetup extends Record = Record
+> {
+ public readonly name: DiscoveredPlugin['id'];
+ public readonly configPath: DiscoveredPlugin['configPath'];
+ public readonly requiredDependencies: DiscoveredPlugin['requiredPlugins'];
+ public readonly optionalDependencies: DiscoveredPlugin['optionalPlugins'];
+ private initializer?: PluginInitializer;
+ private instance?: ReturnType>;
+
+ constructor(
+ public readonly discoveredPlugin: DiscoveredPlugin,
+ private readonly initializerContext: PluginInitializerContext
+ ) {
+ this.name = discoveredPlugin.id;
+ this.configPath = discoveredPlugin.configPath;
+ this.requiredDependencies = discoveredPlugin.requiredPlugins;
+ this.optionalDependencies = discoveredPlugin.optionalPlugins;
+ }
+
+ /**
+ * Loads the plugin's bundle into the browser. Should be called in parallel with all plugins
+ * using `Promise.all`. Must be called before `setup`.
+ * @param addBasePath Function that adds the base path to a string for plugin bundle path.
+ */
+ public async load(addBasePath: (path: string) => string) {
+ this.initializer = await loadPluginBundle(addBasePath, this.name);
+ }
+
+ /**
+ * Instantiates plugin and calls `setup` function exposed by the plugin initializer.
+ * @param setupContext Context that consists of various core services tailored specifically
+ * for the `setup` lifecycle event.
+ * @param dependencies The dictionary where the key is the dependency name and the value
+ * is the contract returned by the dependency's `setup` function.
+ */
+ public async setup(setupContext: PluginSetupContext, dependencies: TDependenciesSetup) {
+ this.instance = await this.createPluginInstance();
+
+ return await this.instance.setup(setupContext, dependencies);
+ }
+
+ /**
+ * Calls optional `stop` function exposed by the plugin initializer.
+ */
+ public async stop() {
+ if (this.instance === undefined) {
+ throw new Error(
+ `Plugin "${this.discoveredPlugin.id}" can't be stopped since it isn't set up.`
+ );
+ }
+
+ if (typeof this.instance.stop === 'function') {
+ await this.instance.stop();
+ }
+
+ this.instance = undefined;
+ }
+
+ private async createPluginInstance() {
+ if (this.initializer === undefined) {
+ throw new Error(`Plugin "${this.name}" can't be setup since its bundle isn't loaded.`);
+ }
+
+ const instance = this.initializer(this.initializerContext);
+
+ if (typeof instance.setup !== 'function') {
+ throw new Error(
+ `Instance of plugin "${this.discoveredPlugin.id}" does not define "setup" function.`
+ );
+ }
+
+ return instance;
+ }
+}
diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts
new file mode 100644
index 00000000000000..097be22ce3f809
--- /dev/null
+++ b/src/core/public/plugins/plugin_context.ts
@@ -0,0 +1,74 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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 { DiscoveredPlugin } from '../../server';
+import { BasePathSetup } from '../base_path';
+import { ChromeSetup } from '../chrome';
+import { CoreContext } from '../core_system';
+import { FatalErrorsSetup } from '../fatal_errors';
+import { I18nSetup } from '../i18n';
+import { NotificationsSetup } from '../notifications';
+import { UiSettingsSetup } from '../ui_settings';
+import { Plugin } from './plugin';
+import { PluginsServiceSetupDeps } from './plugins_service';
+
+// tslint:disable-next-line no-empty-interface
+export interface PluginInitializerContext {}
+
+export interface PluginSetupContext {
+ basePath: BasePathSetup;
+ chrome: ChromeSetup;
+ fatalErrors: FatalErrorsSetup;
+ i18n: I18nSetup;
+ notifications: NotificationsSetup;
+ uiSettings: UiSettingsSetup;
+}
+
+/**
+ * Provides a plugin-specific context passed to the plugin's construtor. This is currently
+ * empty but should provide static services in the future, such as config and logging.
+ *
+ * @param coreContext
+ * @param pluginManinfest
+ * @internal
+ */
+export function createPluginInitializerContext(
+ coreContext: CoreContext,
+ pluginManifest: DiscoveredPlugin
+): PluginInitializerContext {
+ return {};
+}
+
+/**
+ * Provides a plugin-specific context passed to the plugin's `setup` lifecycle event. Currently
+ * this returns a shallow copy the service setup contracts, but in the future could provide
+ * plugin-scoped versions of the service.
+ *
+ * @param coreContext
+ * @param deps
+ * @param plugin
+ * @internal
+ */
+export function createPluginSetupContext(
+ coreContext: CoreContext,
+ deps: PluginsServiceSetupDeps,
+ plugin: Plugin
+): PluginSetupContext {
+ return { ...deps };
+}
diff --git a/src/core/public/plugins/plugin_loader.mock.ts b/src/core/public/plugins/plugin_loader.mock.ts
new file mode 100644
index 00000000000000..5f55cbcc867448
--- /dev/null
+++ b/src/core/public/plugins/plugin_loader.mock.ts
@@ -0,0 +1,33 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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 { PluginName } from 'src/core/server';
+import { LoadPluginBundle, UnknownPluginInitializer } from './plugin_loader';
+
+/**
+ * @param initializerProvider A function provided by the test to resolve initializers.
+ */
+const createLoadPluginBundleMock = (
+ initializerProvider: (name: PluginName) => UnknownPluginInitializer
+): jest.Mock, Parameters> =>
+ jest.fn((addBasePath, pluginName) => {
+ return Promise.resolve(initializerProvider(pluginName)) as any;
+ });
+
+export const loadPluginBundleMock = { create: createLoadPluginBundleMock };
diff --git a/src/core/public/plugins/plugin_loader.test.ts b/src/core/public/plugins/plugin_loader.test.ts
new file mode 100644
index 00000000000000..5ec63a80ec25a2
--- /dev/null
+++ b/src/core/public/plugins/plugin_loader.test.ts
@@ -0,0 +1,117 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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 { loadPluginBundle } from './plugin_loader';
+
+let createdScriptTags = [] as any[];
+let appendChildSpy: jest.Mock;
+let createElementSpy: jest.Mock;
+
+beforeEach(() => {
+ // Mock document.createElement to return fake tags we can use to inspect what
+ // loadPluginBundles does.
+ createdScriptTags = [];
+ createElementSpy = jest.spyOn(document, 'createElement').mockImplementation(() => {
+ const scriptTag = { setAttribute: jest.fn() } as any;
+ createdScriptTags.push(scriptTag);
+ return scriptTag;
+ });
+
+ // Mock document.body.appendChild to avoid errors about appending objects that aren't `Node`'s
+ // and so we can verify that the script tags were added to the page.
+ appendChildSpy = jest.spyOn(document.body, 'appendChild').mockReturnValue({} as any);
+
+ // Mock global fields needed for loading modules.
+ window.__kbnNonce__ = 'asdf';
+ window.__kbnBundles__ = {};
+});
+
+afterEach(() => {
+ appendChildSpy.mockRestore();
+ createElementSpy.mockRestore();
+ delete window.__kbnNonce__;
+ delete window.__kbnBundles__;
+});
+
+const addBasePath = (path: string) => path;
+
+test('`loadPluginBundles` creates a script tag and loads initializer', async () => {
+ const loadPromise = loadPluginBundle(addBasePath, 'plugin-a');
+
+ // Verify it sets up the script tag correctly and adds it to document.body
+ expect(createdScriptTags).toHaveLength(1);
+ const fakeScriptTag = createdScriptTags[0];
+ expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith(
+ 'src',
+ '/bundles/plugin/plugin-a.bundle.js'
+ );
+ expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith('id', 'kbn-plugin-plugin-a');
+ expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith('nonce', 'asdf');
+ expect(fakeScriptTag.onload).toBeInstanceOf(Function);
+ expect(fakeScriptTag.onerror).toBeInstanceOf(Function);
+ expect(appendChildSpy).toHaveBeenCalledWith(fakeScriptTag);
+
+ // Setup a fake initializer as if a plugin bundle had actually been loaded.
+ const fakeInitializer = jest.fn();
+ window.__kbnBundles__['plugin/plugin-a'] = fakeInitializer;
+ // Call the onload callback
+ fakeScriptTag.onload();
+ await expect(loadPromise).resolves.toEqual(fakeInitializer);
+});
+
+test('`loadPluginBundles` includes the basePath', async () => {
+ loadPluginBundle((path: string) => `/mybasepath${path}`, 'plugin-a');
+
+ // Verify it sets up the script tag correctly and adds it to document.body
+ expect(createdScriptTags).toHaveLength(1);
+ const fakeScriptTag = createdScriptTags[0];
+ expect(fakeScriptTag.setAttribute).toHaveBeenCalledWith(
+ 'src',
+ '/mybasepath/bundles/plugin/plugin-a.bundle.js'
+ );
+});
+
+test('`loadPluginBundles` rejects if any script fails', async () => {
+ const loadPromise = loadPluginBundle(addBasePath, 'plugin-a');
+
+ const fakeScriptTag1 = createdScriptTags[0];
+
+ // Setup a fake initializer as if a plugin bundle had actually been loaded.
+ const fakeInitializer1 = jest.fn();
+ window.__kbnBundles__['plugin/plugin-a'] = fakeInitializer1;
+ // Call the error on the second script
+ fakeScriptTag1.onerror(new Error('Whoa there!'));
+
+ await expect(loadPromise).rejects.toThrowError('Whoa there!');
+});
+
+test('`loadPluginBundles` rejects if bundle does attach an initializer to window.__kbnBundles__', async () => {
+ const loadPromise = loadPluginBundle(addBasePath, 'plugin-a');
+
+ const fakeScriptTag1 = createdScriptTags[0];
+
+ // Setup a fake initializer as if a plugin bundle had actually been loaded.
+ window.__kbnBundles__['plugin/plugin-a'] = undefined;
+ // Call the onload callback
+ fakeScriptTag1.onload();
+
+ await expect(loadPromise).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Definition of plugin \\"plugin-a\\" should be a function (/bundles/plugin/plugin-a.bundle.js)."`
+ );
+});
diff --git a/src/core/public/plugins/plugin_loader.ts b/src/core/public/plugins/plugin_loader.ts
new file mode 100644
index 00000000000000..299bd8827e1b2f
--- /dev/null
+++ b/src/core/public/plugins/plugin_loader.ts
@@ -0,0 +1,98 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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 { PluginName } from '../../server';
+import { PluginInitializer } from './plugin';
+
+/**
+ * Unknown variant for internal use only for when plugins are not known.
+ * @internal
+ */
+export type UnknownPluginInitializer = PluginInitializer>;
+
+/**
+ * Extend window with types for loading bundles
+ * @internal
+ */
+declare global {
+ interface Window {
+ __kbnNonce__: string;
+ __kbnBundles__: {
+ [pluginBundleName: string]: UnknownPluginInitializer | undefined;
+ };
+ }
+}
+
+/**
+ * Loads the bundle for a plugin onto the page and returns their PluginInitializer. This should
+ * be called for all plugins (once per plugin) in parallel using Promise.all.
+ *
+ * If this is slowing down browser load time, there are some ways we could make this faster:
+ * - Add these bundles in the generated bootstrap.js file so they're loaded immediately
+ * - Concatenate all the bundles files on the backend and serve them in single request.
+ * - Use HTTP/2 to load these bundles without having to open new connections for each.
+ *
+ * This may not be much of an issue since these should be cached by the browser after the first
+ * page load.
+ *
+ * @param basePath
+ * @param plugins
+ * @internal
+ */
+export const loadPluginBundle: LoadPluginBundle = <
+ TSetup,
+ TDependencies extends Record
+>(
+ addBasePath: (path: string) => string,
+ pluginName: PluginName
+) =>
+ new Promise>((resolve, reject) => {
+ const script = document.createElement('script');
+
+ // Assumes that all plugin bundles get put into the bundles/plugins subdirectory
+ const bundlePath = addBasePath(`/bundles/plugin/${pluginName}.bundle.js`);
+ script.setAttribute('src', bundlePath);
+ script.setAttribute('id', `kbn-plugin-${pluginName}`);
+ script.setAttribute('async', '');
+
+ // Add kbnNonce for CSP
+ script.setAttribute('nonce', window.__kbnNonce__);
+
+ // Wire up resolve and reject
+ script.onload = () => {
+ const initializer = window.__kbnBundles__[`plugin/${pluginName}`];
+
+ if (!initializer || typeof initializer !== 'function') {
+ reject(
+ new Error(`Definition of plugin "${pluginName}" should be a function (${bundlePath}).`)
+ );
+ } else {
+ resolve(initializer as PluginInitializer);
+ }
+ };
+ script.onerror = reject;
+
+ // Add the script tag to the end of the body to start downloading
+ document.body.appendChild(script);
+ });
+
+export type LoadPluginBundle = >(
+ addBasePath: (path: string) => string,
+ pluginName: PluginName
+) => Promise>;
diff --git a/src/core/public/plugins/plugins_service.mock.ts b/src/core/public/plugins/plugins_service.mock.ts
new file mode 100644
index 00000000000000..d4c6ffb12aaefa
--- /dev/null
+++ b/src/core/public/plugins/plugins_service.mock.ts
@@ -0,0 +1,44 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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 { PluginsService, PluginsServiceSetup } from './plugins_service';
+
+const createSetupContractMock = () => {
+ const setupContract: jest.Mocked> = {
+ pluginSetups: new Map(),
+ };
+ // we have to suppress type errors until decide how to mock es6 class
+ return (setupContract as unknown) as PluginsServiceSetup;
+};
+
+type PluginsServiceContract = PublicMethodsOf;
+const createMock = () => {
+ const mocked: jest.Mocked = {
+ setup: jest.fn(),
+ stop: jest.fn(),
+ };
+
+ mocked.setup.mockResolvedValue(createSetupContractMock());
+ return mocked;
+};
+
+export const pluginsServiceMock = {
+ create: createMock,
+ createSetupContract: createSetupContractMock,
+};
diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts
new file mode 100644
index 00000000000000..70590f2b7edc00
--- /dev/null
+++ b/src/core/public/plugins/plugins_service.test.ts
@@ -0,0 +1,193 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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 { PluginName } from 'src/core/server';
+import { CoreContext } from '../core_system';
+import { Plugin } from './plugin';
+import { loadPluginBundleMock } from './plugin_loader.mock';
+
+type MockedPluginInitializer = jest.Mock>, any>;
+
+let mockPluginInitializers: Map;
+
+const mockLoadPluginBundle = loadPluginBundleMock.create(
+ (pluginName: string) => mockPluginInitializers.get(pluginName)!
+);
+jest.mock('./plugin_loader', () => ({
+ loadPluginBundle: mockLoadPluginBundle,
+}));
+
+import { PluginsService } from './plugins_service';
+
+const mockCoreContext: CoreContext = {};
+let mockDeps: any;
+
+beforeEach(() => {
+ mockDeps = {
+ injectedMetadata: {
+ getPlugins: jest.fn(() => [
+ { id: 'pluginA', plugin: createManifest('pluginA') },
+ { id: 'pluginB', plugin: createManifest('pluginB', { required: ['pluginA'] }) },
+ {
+ id: 'pluginC',
+ plugin: createManifest('pluginC', { required: ['pluginA'], optional: ['nonexist'] }),
+ },
+ ]),
+ },
+ basePath: {
+ addToPath(path: string) {
+ return path;
+ },
+ },
+ } as any;
+
+ // Reset these for each test.
+ mockPluginInitializers = new Map(([
+ [
+ 'pluginA',
+ jest.fn(() => ({
+ setup: jest.fn(() => ({ exportedValue: 1 })),
+ stop: jest.fn(),
+ })),
+ ],
+ [
+ 'pluginB',
+ jest.fn(() => ({
+ setup: jest.fn((core, deps: any) => ({
+ pluginAPlusB: deps.pluginA.exportedValue + 1,
+ })),
+ stop: jest.fn(),
+ })),
+ ],
+ [
+ 'pluginC',
+ jest.fn(() => ({
+ setup: jest.fn(),
+ stop: jest.fn(),
+ })),
+ ],
+ ] as unknown) as [[PluginName, any]]);
+});
+
+afterEach(() => {
+ mockLoadPluginBundle.mockClear();
+});
+
+function createManifest(
+ id: string,
+ { required = [], optional = [] }: { required?: string[]; optional?: string[]; ui?: boolean } = {}
+) {
+ return {
+ id,
+ version: 'some-version',
+ configPath: ['path'],
+ requiredPlugins: required,
+ optionalPlugins: optional,
+ };
+}
+
+test('`PluginsService.setup` fails if any bundle cannot be loaded', async () => {
+ mockLoadPluginBundle.mockRejectedValueOnce(new Error('Could not load bundle'));
+
+ const pluginsService = new PluginsService(mockCoreContext);
+ await expect(pluginsService.setup(mockDeps)).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Could not load bundle"`
+ );
+});
+
+test('`PluginsService.setup` fails if any plugin instance does not have a setup function', async () => {
+ mockPluginInitializers.set('pluginA', (() => ({})) as any);
+ const pluginsService = new PluginsService(mockCoreContext);
+ await expect(pluginsService.setup(mockDeps)).rejects.toThrowErrorMatchingInlineSnapshot(
+ `"Instance of plugin \\"pluginA\\" does not define \\"setup\\" function."`
+ );
+});
+
+test('`PluginsService.setup` calls loadPluginBundles with basePath and plugins', async () => {
+ const pluginsService = new PluginsService(mockCoreContext);
+ await pluginsService.setup(mockDeps);
+
+ expect(mockLoadPluginBundle).toHaveBeenCalledTimes(3);
+ expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockDeps.basePath.addToPath, 'pluginA');
+ expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockDeps.basePath.addToPath, 'pluginB');
+ expect(mockLoadPluginBundle).toHaveBeenCalledWith(mockDeps.basePath.addToPath, 'pluginC');
+});
+
+test('`PluginsService.setup` initalizes plugins with CoreContext', async () => {
+ const pluginsService = new PluginsService(mockCoreContext);
+ await pluginsService.setup(mockDeps);
+
+ expect(mockPluginInitializers.get('pluginA')).toHaveBeenCalledWith(mockCoreContext);
+ expect(mockPluginInitializers.get('pluginB')).toHaveBeenCalledWith(mockCoreContext);
+ expect(mockPluginInitializers.get('pluginC')).toHaveBeenCalledWith(mockCoreContext);
+});
+
+test('`PluginsService.setup` exposes dependent setup contracts to plugins', async () => {
+ const pluginsService = new PluginsService(mockCoreContext);
+ await pluginsService.setup(mockDeps);
+
+ const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value;
+ const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value;
+ const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value;
+
+ expect(pluginAInstance.setup).toHaveBeenCalledWith(mockDeps, {});
+ expect(pluginBInstance.setup).toHaveBeenCalledWith(mockDeps, { pluginA: { exportedValue: 1 } });
+ // Does not supply value for `nonexist` optional dep
+ expect(pluginCInstance.setup).toHaveBeenCalledWith(mockDeps, { pluginA: { exportedValue: 1 } });
+});
+
+test('`PluginsService.setup` does not set missing dependent setup contracts', async () => {
+ mockDeps.injectedMetadata.getPlugins.mockReturnValue([
+ { id: 'pluginD', plugin: createManifest('pluginD', { required: ['missing'] }) },
+ ]);
+ mockPluginInitializers.set('pluginD', jest.fn(() => ({ setup: jest.fn() })) as any);
+
+ const pluginsService = new PluginsService(mockCoreContext);
+ await pluginsService.setup(mockDeps);
+
+ // If a dependency is missing it should not be in the deps at all, not even as undefined.
+ const pluginDInstance = mockPluginInitializers.get('pluginD')!.mock.results[0].value;
+ expect(pluginDInstance.setup).toHaveBeenCalledWith(mockDeps, {});
+ const pluginDDeps = pluginDInstance.setup.mock.calls[0][1];
+ expect(pluginDDeps).not.toHaveProperty('missing');
+});
+
+test('`PluginsService.setup` returns plugin setup contracts', async () => {
+ const pluginsService = new PluginsService(mockCoreContext);
+ const { contracts } = await pluginsService.setup(mockDeps);
+
+ // Verify that plugin contracts were available
+ expect((contracts.get('pluginA')! as any).exportedValue).toEqual(1);
+ expect((contracts.get('pluginB')! as any).pluginAPlusB).toEqual(2);
+});
+
+test('`PluginService.stop` calls the stop function on each plugin', async () => {
+ const pluginsService = new PluginsService(mockCoreContext);
+ await pluginsService.setup(mockDeps);
+
+ const pluginAInstance = mockPluginInitializers.get('pluginA')!.mock.results[0].value;
+ const pluginBInstance = mockPluginInitializers.get('pluginB')!.mock.results[0].value;
+ const pluginCInstance = mockPluginInitializers.get('pluginC')!.mock.results[0].value;
+
+ await pluginsService.stop();
+
+ expect(pluginAInstance.stop).toHaveBeenCalled();
+ expect(pluginBInstance.stop).toHaveBeenCalled();
+ expect(pluginCInstance.stop).toHaveBeenCalled();
+});
diff --git a/src/core/public/plugins/plugins_service.ts b/src/core/public/plugins/plugins_service.ts
new file mode 100644
index 00000000000000..62571c7cf6fe6e
--- /dev/null
+++ b/src/core/public/plugins/plugins_service.ts
@@ -0,0 +1,101 @@
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you 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 { CoreSetup } from '..';
+import { CoreService } from '../../types';
+import { CoreContext } from '../core_system';
+import { Plugin } from './plugin';
+import { createPluginInitializerContext, createPluginSetupContext } from './plugin_context';
+
+/** @internal */
+export type PluginsServiceSetupDeps = CoreSetup;
+
+/** @internal */
+export interface PluginsServiceSetup {
+ contracts: Map;
+}
+
+/**
+ * Service responsible for loading plugin bundles, initializing plugins, and managing the lifecycle
+ * of all plugins.
+ *
+ * @internal
+ */
+// tslint:disable max-classes-per-file
+export class PluginsService implements CoreService {
+ private readonly plugins: Map>> = new Map();
+
+ constructor(private readonly coreContext: CoreContext) {}
+
+ public async setup(deps: PluginsServiceSetupDeps) {
+ // Construct plugin wrappers
+ deps.injectedMetadata
+ .getPlugins()
+ .forEach(({ id, plugin }) =>
+ this.plugins.set(id, new Plugin(plugin, createPluginInitializerContext(deps, plugin)))
+ );
+
+ // Load plugin bundles
+ await this.loadPluginBundles(deps.basePath.addToPath);
+
+ // Setup each plugin with correct dependencies
+ const contracts = new Map();
+ for (const [pluginName, plugin] of this.plugins.entries()) {
+ const dependencies = new Set([
+ ...plugin.requiredDependencies,
+ ...plugin.optionalDependencies.filter(optPlugin => this.plugins.get(optPlugin)),
+ ]);
+
+ const dependencyContracts = [...dependencies.keys()].reduce(
+ (depContracts, dependency) => {
+ // Only set if present. Could be absent if plugin does not have client-side code or is a
+ // missing optional dependency.
+ if (contracts.get(dependency)) {
+ depContracts[dependency] = contracts.get(dependency);
+ }
+ return depContracts;
+ },
+ {} as { [dep: string]: unknown }
+ );
+
+ contracts.set(
+ pluginName,
+ await plugin.setup(
+ createPluginSetupContext(this.coreContext, deps, plugin),
+ dependencyContracts
+ )
+ );
+ }
+
+ // Expose setup contracts
+ return { contracts };
+ }
+
+ public async stop() {
+ // Stop plugins in reverse dependency order.
+ for (const plugin of [...this.plugins.values()].reverse()) {
+ await plugin.stop();
+ }
+ }
+
+ private loadPluginBundles(addBasePath: (path: string) => string) {
+ // Load all bundles in parallel
+ return Promise.all([...this.plugins.values()].map(plugin => plugin.load(addBasePath)));
+ }
+}
diff --git a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js
index 99238fcd1c9ccc..9a981e72e9c6e0 100644
--- a/src/legacy/core_plugins/tests_bundle/tests_entry_template.js
+++ b/src/legacy/core_plugins/tests_bundle/tests_entry_template.js
@@ -58,6 +58,7 @@ new CoreSystem({
csp: {
warnLegacyBrowsers: false,
},
+ uiPlugins: [],
vars: {
kbnIndex: '.kibana',
esShardTimeout: 1500,
diff --git a/src/legacy/ui/ui_bundles/app_entry_template.js b/src/legacy/ui/ui_bundles/app_entry_template.js
index 2d92dc30556fb9..16a41cedcc7439 100644
--- a/src/legacy/ui/ui_bundles/app_entry_template.js
+++ b/src/legacy/ui/ui_bundles/app_entry_template.js
@@ -56,10 +56,12 @@ i18n.load(injectedMetadata.i18n.translationsUrl)
}
});
- const coreStart = coreSystem.setup();
-
- if (i18nError) {
- coreStart.fatalErrors.add(i18nError);
- }
+ coreSystem
+ .setup()
+ .then((coreSetup) => {
+ if (i18nError) {
+ coreSetup.fatalErrors.add(i18nError);
+ }
+ });
});
`;