From c193e2d1c4665cb3a2f96bfa45a36e727b6cc2a0 Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Wed, 5 Nov 2025 22:03:40 +0000 Subject: [PATCH] Add support for loading plugins from local manifests --- package.json | 1 + packages/lib-core/CHANGELOG.md | 20 +++---- packages/lib-core/src/index.ts | 3 + packages/lib-core/src/runtime/PluginLoader.ts | 60 +++++++++++-------- packages/lib-core/src/runtime/PluginStore.ts | 43 ++++++++----- .../lib-core/src/runtime/plugin-manifest.ts | 8 +++ .../lib-core/src/testing/TestPluginStore.ts | 19 ++---- packages/lib-core/src/types/extension.ts | 6 +- packages/lib-core/src/types/loader.ts | 12 ++-- packages/lib-core/src/types/plugin.ts | 26 ++++++-- packages/lib-core/src/types/store.ts | 9 +-- packages/lib-webpack/CHANGELOG.md | 12 ++-- .../src/components/PageContent.cy.tsx | 44 ++++---------- .../src/components/PluginInfoTable.cy.tsx | 21 ++----- .../src/components/PluginInfoTable.tsx | 6 +- packages/sample-app/src/test-mocks.ts | 26 ++------ reports/lib-core.api.md | 56 ++++++++++------- 17 files changed, 196 insertions(+), 176 deletions(-) create mode 100644 packages/lib-core/src/runtime/plugin-manifest.ts diff --git a/package.json b/package.json index 2900de90..fe4dc631 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "eslint-print-config": "yarn eslint --print-config", "jest": "TZ=UTC jest --passWithNoTests", "jest-print-config": "jest --showConfig", + "sample-app-install-cypress": "yarn workspace @monorepo/sample-app run cypress install", "sample-app-test-component": "yarn workspace @monorepo/sample-app run cypress run --component", "sample-app-test-e2e": "yarn workspace @monorepo/sample-app run cypress run --e2e", "api-extractor": "api-extractor run --typescript-compiler-folder ./node_modules/typescript/lib" diff --git a/packages/lib-core/CHANGELOG.md b/packages/lib-core/CHANGELOG.md index 87232276..36a38d90 100644 --- a/packages/lib-core/CHANGELOG.md +++ b/packages/lib-core/CHANGELOG.md @@ -1,13 +1,12 @@ # Changelog for `@openshift/dynamic-plugin-sdk` -> Changes prefixed with [!] refer to API breaking changes. - ## 6.0.0 - TODO > TODO highlight major changes for this release here. - Add support for optional dependencies ([#273]) -- [!] Move plugin manifest extension post-processing to `PluginLoader.loadPlugin` ([#280]) +- Add support for loading plugins from local manifests ([#281]) +- BREAKING: Move plugin manifest extension post-processing to `PluginLoader.loadPlugin` ([#280]) - Improve code reference types and make them support optional chaining ([#274]) ## 5.0.1 - 2024-01-15 @@ -20,7 +19,7 @@ > the `PluginStore`. Note that the `useResolvedExtensions` hook does not automatically disable > plugins whose extensions have code reference resolution errors. -- [!] Rename `postProcessManifest` loader option to `transformPluginManifest` ([#236]) +- BREAKING: Rename `postProcessManifest` loader option to `transformPluginManifest` ([#236]) - Support passing custom plugin loader implementation to `PluginStore` ([#232]) - Add `TestPluginStore` intended for React component testing purposes ([#232]) - Add options to `useResolvedExtensions` hook to customize its default behavior ([#241]) @@ -30,10 +29,10 @@ > This release removes the `PluginLoader` export. Pass the former `PluginLoader` > options object as `loaderOptions` when creating the `PluginStore`. -- [!] Modify `PluginStore.loadPlugin` signature to accept plugin manifest ([#212]) -- [!] Ensure `PluginStore.loadPlugin` returns the same Promise for pending plugins ([#212]) -- [!] Treat `PluginLoader` as an implementation detail of `PluginStore` ([#212]) -- [!] Replace `entryCallbackName` loader option with `entryCallbackSettings.name` ([#212]) +- BREAKING: Modify `PluginStore.loadPlugin` signature to accept plugin manifest ([#212]) +- BREAKING: Ensure `PluginStore.loadPlugin` returns the same Promise for pending plugins ([#212]) +- BREAKING: Treat `PluginLoader` as an implementation detail of `PluginStore` ([#212]) +- BREAKING: Replace `entryCallbackName` loader option with `entryCallbackSettings.name` ([#212]) - Add `entryCallbackSettings.autoRegisterCallback` loader option ([#212]) - Support tracking pending plugins via `PluginStore.getPluginInfo` ([#212]) - Provide access to raw plugin manifest in all `PluginInfoEntry` objects ([#212]) @@ -47,7 +46,7 @@ - Allow plugins to pass custom properties via plugin manifest ([#204]) - Add `sdkVersion` to `PluginStore` for better runtime diagnostics ([#200]) - Provide direct access to raw plugin manifest data ([#207]) -- [!] Remove `PluginStore` option `postProcessExtensions` ([#207]) +- BREAKING: Remove `PluginStore` option `postProcessExtensions` ([#207]) - Add technical compatibility with React 18 ([#208]) ## 2.0.1 - 2023-01-27 @@ -63,7 +62,7 @@ - Allow reloading plugins which are already loaded ([#182]) - Allow providing custom manifest object in `PluginStore.loadPlugin` ([#182]) - Provide direct access to plugin modules via `PluginStore.getExposedModule` ([#180]) -- [!] Fix `useResolvedExtensions` hook to reset result before restarting resolution ([#182]) +- BREAKING: Fix `useResolvedExtensions` hook to reset result before restarting resolution ([#182]) - Ensure that all APIs referenced through the package index are exported ([#184]) ## 1.0.0 - 2022-10-27 @@ -87,3 +86,4 @@ [#273]: https://github.com/openshift/dynamic-plugin-sdk/pull/273 [#274]: https://github.com/openshift/dynamic-plugin-sdk/pull/274 [#280]: https://github.com/openshift/dynamic-plugin-sdk/pull/280 +[#281]: https://github.com/openshift/dynamic-plugin-sdk/pull/281 diff --git a/packages/lib-core/src/index.ts b/packages/lib-core/src/index.ts index a61fc7b8..f148c43f 100644 --- a/packages/lib-core/src/index.ts +++ b/packages/lib-core/src/index.ts @@ -43,6 +43,7 @@ export { useFeatureFlag, UseFeatureFlagResult } from './runtime/useFeatureFlag'; // Core utilities export { applyCodeRefSymbol } from './runtime/coderefs'; +export { isLocalPluginManifest, isStandardPluginManifest } from './runtime/plugin-manifest'; // Testing utilities export { TestPluginStore } from './testing/TestPluginStore'; @@ -69,6 +70,8 @@ export { PluginRegistrationMethod, PluginRuntimeMetadata, PluginManifest, + LocalPluginManifest, + AnyPluginManifest, PendingPlugin, LoadedPlugin, FailedPlugin, diff --git a/packages/lib-core/src/runtime/PluginLoader.ts b/packages/lib-core/src/runtime/PluginLoader.ts index 74749c1a..ab51ba16 100644 --- a/packages/lib-core/src/runtime/PluginLoader.ts +++ b/packages/lib-core/src/runtime/PluginLoader.ts @@ -7,7 +7,7 @@ import { DEFAULT_REMOTE_ENTRY_CALLBACK } from '../constants'; import type { LoadedExtension } from '../types/extension'; import type { ResourceFetch } from '../types/fetch'; import type { PluginLoadResult, PluginLoaderInterface } from '../types/loader'; -import type { PluginManifest } from '../types/plugin'; +import type { PluginManifest, AnyPluginManifest } from '../types/plugin'; import type { PluginEntryModule, PluginEntryCallback } from '../types/runtime'; import { basicFetch } from '../utils/basic-fetch'; import { settleAllPromises } from '../utils/promise'; @@ -15,6 +15,7 @@ import { injectScriptElement, getScriptElement } from '../utils/scripts'; import { resolveURL } from '../utils/url'; import { pluginManifestSchema } from '../yup-schemas'; import { decodeCodeRefs } from './coderefs'; +import { isStandardPluginManifest } from './plugin-manifest'; declare global { interface Window { @@ -24,7 +25,7 @@ declare global { type PluginLoadData = { status: 'pending' | 'loaded' | 'failed'; - manifest: PluginManifest; + manifest: AnyPluginManifest; entryCallbackFired?: boolean; entryCallbackModule?: PluginEntryModule; }; @@ -46,7 +47,7 @@ export type PluginLoaderOptions = Partial<{ * * By default, all plugins are allowed to be loaded and reloaded. */ - canLoadPlugin: (manifest: PluginManifest, reload: boolean) => boolean; + canLoadPlugin: (manifest: AnyPluginManifest, reload: boolean) => boolean; /** * Control whether the given plugin script can be reloaded when attempting to reload @@ -121,7 +122,7 @@ export type PluginLoaderOptions = Partial<{ * * By default, no transformation is performed on the manifest. */ - transformPluginManifest: (manifest: PluginManifest) => PluginManifest; + transformPluginManifest: (manifest: T) => T; /** * Provide access to the plugin's entry module. @@ -174,10 +175,10 @@ export class PluginLoader implements PluginLoaderInterface { pluginManifestSchema.validateSync(manifest, { strict: true, abortEarly: false }); - return manifest; + return manifest as PluginManifest; } - transformPluginManifest(manifest: PluginManifest) { + transformPluginManifest(manifest: T) { return this.options.transformPluginManifest(manifest); } @@ -191,15 +192,17 @@ export class PluginLoader implements PluginLoaderInterface { * For plugins using the `custom` registration method, the `getPluginEntryModule` function * is expected to return the entry module of the given plugin. If not implemented properly, * plugins using the `custom` registration method will fail to load. + * + * For plugins loaded from a local plugin manifest, the `entryModule` will be `undefined`. */ - async loadPlugin(manifest: PluginManifest): Promise { + async loadPlugin(manifest: AnyPluginManifest): Promise { const pluginName = manifest.name; const reload = this.plugins.has(pluginName); const data: PluginLoadData = { status: 'pending', manifest }; - let entryModule: PluginEntryModule; + let entryModule: PluginEntryModule | undefined; - if (manifest.registrationMethod === 'callback') { + if (isStandardPluginManifest(manifest) && manifest.registrationMethod === 'callback') { data.entryCallbackFired = false; } @@ -229,7 +232,9 @@ export class PluginLoader implements PluginLoaderInterface { } try { - await this.loadPluginScripts(manifest, data); + if (isStandardPluginManifest(manifest)) { + await this.loadPluginScripts(manifest, data); + } } catch (e) { data.status = 'failed'; this.invokeLoadListeners(); @@ -242,7 +247,9 @@ export class PluginLoader implements PluginLoaderInterface { } try { - entryModule = await this.initSharedModules(manifest, data); + if (isStandardPluginManifest(manifest)) { + entryModule = await this.initSharedModules(manifest, data); + } } catch (e) { data.status = 'failed'; this.invokeLoadListeners(); @@ -254,21 +261,25 @@ export class PluginLoader implements PluginLoaderInterface { }; } - const loadedExtensions = cloneDeep(manifest.extensions).map((e, index) => - decodeCodeRefs( - { - ...e, - pluginName, - uid: `${pluginName}[${index}]_${manifest.buildHash ?? uuidv4()}`, - }, - entryModule, - ), - ); + const pluginBuildHash = isStandardPluginManifest(manifest) + ? manifest.buildHash ?? uuidv4() + : uuidv4(); + + let loadedExtensions = cloneDeep(manifest.extensions).map((e, index) => ({ + ...e, + pluginName, + uid: `${pluginName}[${index}]_${pluginBuildHash}`, + })); + + if (isStandardPluginManifest(manifest)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + loadedExtensions = loadedExtensions.map((e) => decodeCodeRefs(e, entryModule!)); + } data.status = 'loaded'; this.invokeLoadListeners(); - return { success: true, entryModule, loadedExtensions }; + return { success: true, loadedExtensions, entryModule }; } /** @@ -361,7 +372,7 @@ export class PluginLoader implements PluginLoaderInterface { * * Fail early if there are any unsuccessful or unmet dependency resolutions. */ - private resolvePluginDependencies(manifest: PluginManifest) { + private resolvePluginDependencies(manifest: AnyPluginManifest) { return new Promise((resolve, reject) => { const pluginName = manifest.name; const requiredDependencies = manifest.dependencies ?? {}; @@ -458,7 +469,6 @@ export class PluginLoader implements PluginLoaderInterface { const callbackName = this.options.entryCallbackSettings.name ?? DEFAULT_REMOTE_ENTRY_CALLBACK; if (!registerCallback) { - consoleLogger.info(`Plugin entry callback ${callbackName} will not be registered`); return; } @@ -468,5 +478,7 @@ export class PluginLoader implements PluginLoaderInterface { } window[callbackName] = this.createPluginEntryCallback(); + + consoleLogger.info(`Plugin entry callback ${callbackName} has been registered`); } } diff --git a/packages/lib-core/src/runtime/PluginStore.ts b/packages/lib-core/src/runtime/PluginStore.ts index 7b4bc0ef..8e42871c 100644 --- a/packages/lib-core/src/runtime/PluginStore.ts +++ b/packages/lib-core/src/runtime/PluginStore.ts @@ -4,7 +4,7 @@ import { compact, isEqual, noop, pickBy } from 'lodash'; import { version as sdkVersion } from '../../package.json'; import type { LoadedExtension } from '../types/extension'; import type { PluginLoaderInterface } from '../types/loader'; -import type { PluginManifest, PendingPlugin, LoadedPlugin, FailedPlugin } from '../types/plugin'; +import type { AnyPluginManifest, PendingPlugin, LoadedPlugin, FailedPlugin } from '../types/plugin'; import type { PluginEntryModule } from '../types/runtime'; import type { PluginInfoEntry, PluginStoreInterface, FeatureFlags } from '../types/store'; import { PluginEventType } from '../types/store'; @@ -160,12 +160,15 @@ export class PluginStore implements PluginStoreInterface { } } - async loadPlugin(manifest: PluginManifest | string, forceReload?: boolean) { - let loadedManifest: PluginManifest; + async loadPlugin(manifest: AnyPluginManifest | string, forceReload?: boolean) { + let loadedManifest: AnyPluginManifest; try { - loadedManifest = - typeof manifest === 'string' ? await this.loader.loadPluginManifest(manifest) : manifest; + if (typeof manifest === 'string') { + loadedManifest = await this.loader.loadPluginManifest(manifest); + } else { + loadedManifest = manifest; + } } catch (e) { throw new ErrorWithCause('Failed to load plugin manifest', e); } @@ -190,9 +193,9 @@ export class PluginStore implements PluginStoreInterface { const result = await this.loader.loadPlugin(loadedManifest); if (result.success) { - const { entryModule, loadedExtensions } = result; + const { loadedExtensions, entryModule } = result; - this.addLoadedPlugin(loadedManifest, entryModule, loadedExtensions); + this.addLoadedPlugin(loadedManifest, loadedExtensions, entryModule); consoleLogger.info(`Plugin ${pluginName} has been loaded`); @@ -288,10 +291,11 @@ export class PluginStore implements PluginStoreInterface { } } - protected addPendingPlugin(manifest: PluginManifest) { + protected addPendingPlugin(manifest: AnyPluginManifest) { const pluginName = manifest.name; + const pendingPlugin: PendingPlugin = { manifest }; - this.pendingPlugins.set(pluginName, { manifest }); + this.pendingPlugins.set(pluginName, pendingPlugin); this.loadedPlugins.delete(pluginName); this.failedPlugins.delete(pluginName); @@ -300,14 +304,14 @@ export class PluginStore implements PluginStoreInterface { } /** - * Add a loaded plugin to the {@link PluginStore}. + * @remarks * * Once added, the plugin is disabled by default. Enable it to put its extensions into use. */ protected addLoadedPlugin( - manifest: PluginManifest, - entryModule: PluginEntryModule, + manifest: AnyPluginManifest, loadedExtensions: LoadedExtension[], + entryModule?: PluginEntryModule, ) { const pluginName = manifest.name; @@ -327,12 +331,17 @@ export class PluginStore implements PluginStoreInterface { this.updateExtensions(); } - protected addFailedPlugin(manifest: PluginManifest, errorMessage: string, errorCause?: unknown) { + protected addFailedPlugin( + manifest: AnyPluginManifest, + errorMessage: string, + errorCause?: unknown, + ) { const pluginName = manifest.name; + const failedPlugin: FailedPlugin = { manifest, errorMessage, errorCause }; this.pendingPlugins.delete(pluginName); this.loadedPlugins.delete(pluginName); - this.failedPlugins.set(pluginName, { manifest, errorMessage, errorCause }); + this.failedPlugins.set(pluginName, failedPlugin); this.invokeListeners(PluginEventType.PluginInfoChanged); this.updateExtensions(); @@ -348,6 +357,12 @@ export class PluginStore implements PluginStoreInterface { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const plugin = this.loadedPlugins.get(pluginName)!; + if (!plugin.entryModule) { + throw new Error( + `Attempt to get module '${moduleName}' of plugin ${pluginName} which has no entry module`, + ); + } + const referencedModule = await getPluginModule( moduleName, plugin.entryModule, diff --git a/packages/lib-core/src/runtime/plugin-manifest.ts b/packages/lib-core/src/runtime/plugin-manifest.ts new file mode 100644 index 00000000..16b8cede --- /dev/null +++ b/packages/lib-core/src/runtime/plugin-manifest.ts @@ -0,0 +1,8 @@ +import type { PluginManifest, LocalPluginManifest, AnyPluginManifest } from '../types/plugin'; + +export const isLocalPluginManifest = ( + manifest: AnyPluginManifest, +): manifest is LocalPluginManifest => (manifest as LocalPluginManifest).$local === true; + +export const isStandardPluginManifest = (manifest: AnyPluginManifest): manifest is PluginManifest => + !isLocalPluginManifest(manifest); diff --git a/packages/lib-core/src/testing/TestPluginStore.ts b/packages/lib-core/src/testing/TestPluginStore.ts index 8ad34a28..d57dea8d 100644 --- a/packages/lib-core/src/testing/TestPluginStore.ts +++ b/packages/lib-core/src/testing/TestPluginStore.ts @@ -1,28 +1,21 @@ import { PluginStore } from '../runtime/PluginStore'; -import type { LoadedExtension } from '../types/extension'; -import type { PluginManifest } from '../types/plugin'; -import type { PluginEntryModule } from '../types/runtime'; /** * `PluginStore` implementation intended for testing purposes. */ export class TestPluginStore extends PluginStore { // Override to change access to public - override addPendingPlugin(manifest: PluginManifest) { - super.addPendingPlugin(manifest); + override addPendingPlugin(...args: Parameters) { + super.addPendingPlugin(...args); } // Override to change access to public - override addLoadedPlugin( - manifest: PluginManifest, - entryModule: PluginEntryModule, - loadedExtensions: LoadedExtension[], - ) { - super.addLoadedPlugin(manifest, entryModule, loadedExtensions); + override addLoadedPlugin(...args: Parameters) { + super.addLoadedPlugin(...args); } // Override to change access to public - override addFailedPlugin(manifest: PluginManifest, errorMessage: string, errorCause?: unknown) { - super.addFailedPlugin(manifest, errorMessage, errorCause); + override addFailedPlugin(...args: Parameters) { + super.addFailedPlugin(...args); } } diff --git a/packages/lib-core/src/types/extension.ts b/packages/lib-core/src/types/extension.ts index 5f0d43e5..f8ff195f 100644 --- a/packages/lib-core/src/types/extension.ts +++ b/packages/lib-core/src/types/extension.ts @@ -30,9 +30,9 @@ export type ExtensionFlags = Partial<{ * ``` * * The `properties` object may contain code references represented as {@link CodeRef} - * values. Each code reference should be resolved (its value loaded over the network) - * only when needed. Therefore, any code reference resolution errors should be handled - * as part of interpreting the given extension type. + * values. Each code reference should be resolved (e.g. referenced value loaded over + * the network via `import()` function) only when needed. Therefore, any code reference + * resolution errors should be handled as part of interpreting the given extension type. * * Extensions may also use feature flags to express condition(s) of their enablement. * diff --git a/packages/lib-core/src/types/loader.ts b/packages/lib-core/src/types/loader.ts index a80cf63d..8d9a252a 100644 --- a/packages/lib-core/src/types/loader.ts +++ b/packages/lib-core/src/types/loader.ts @@ -1,12 +1,12 @@ import type { LoadedExtension } from './extension'; -import type { PluginManifest } from './plugin'; +import type { PluginManifest, AnyPluginManifest } from './plugin'; import type { PluginEntryModule } from './runtime'; export type PluginLoadResult = | { success: true; - entryModule: PluginEntryModule; loadedExtensions: LoadedExtension[]; + entryModule?: PluginEntryModule; } | { success: false; @@ -19,7 +19,7 @@ export type PluginLoadResult = */ export type PluginLoaderInterface = { /** - * Load a plugin manifest from the given URL. + * Load a standard plugin manifest from the given URL. * * The implementation should validate the manifest object as necessary. */ @@ -28,16 +28,16 @@ export type PluginLoaderInterface = { /** * Transform the plugin manifest before loading the associated plugin. */ - transformPluginManifest: (manifest: PluginManifest) => PluginManifest; + transformPluginManifest: (manifest: T) => T; /** * Load a plugin from the given manifest. * * The implementation is responsible for decoding any code references in extensions - * listed in the plugin manifest. + * listed in the plugin manifest (except when loading from a local plugin manifest). * * The resulting Promise never rejects; any plugin load error(s) will be contained * within the {@link PluginLoadResult} object. */ - loadPlugin: (manifest: PluginManifest) => Promise; + loadPlugin: (manifest: AnyPluginManifest) => Promise; }; diff --git a/packages/lib-core/src/types/plugin.ts b/packages/lib-core/src/types/plugin.ts index 927b1069..524960c3 100644 --- a/packages/lib-core/src/types/plugin.ts +++ b/packages/lib-core/src/types/plugin.ts @@ -40,6 +40,10 @@ export type PluginRuntimeMetadata = { * * The `baseURL` should be used when loading all plugin assets, including the ones listed in * `loadScripts`. + * + * This is the standard (webpack) representation of a plugin manifest, where we need to load + * the specified scripts in order to initialize the plugin and access its exposed modules via + * the plugin's entry module. */ export type PluginManifest = PluginRuntimeMetadata & { baseURL: string; @@ -49,20 +53,34 @@ export type PluginManifest = PluginRuntimeMetadata & { buildHash?: string; }; +/** + * Plugin manifest object, created directly in your application. + * + * This is the local (manual) representation of a plugin manifest; any code references in + * the `extensions` list should be represented as `CodeRef` functions. Plugins defined this + * way will have no entry module. + */ +export type LocalPluginManifest = PluginRuntimeMetadata & { + extensions: Extension[]; + $local: true; +}; + +export type AnyPluginManifest = PluginManifest | LocalPluginManifest; + /** * Internal entry on a plugin in `pending` state. */ export type PendingPlugin = { - manifest: Readonly; + manifest: Readonly; }; /** * Internal entry on a plugin in `loaded` state. */ export type LoadedPlugin = { - manifest: Readonly; + manifest: Readonly; loadedExtensions: Readonly; - entryModule: PluginEntryModule; + entryModule?: PluginEntryModule; enabled: boolean; disableReason?: string; }; @@ -71,7 +89,7 @@ export type LoadedPlugin = { * Internal entry on a plugin in `failed` state. */ export type FailedPlugin = { - manifest: Readonly; + manifest: Readonly; errorMessage: string; errorCause?: unknown; }; diff --git a/packages/lib-core/src/types/store.ts b/packages/lib-core/src/types/store.ts index 1fb6718c..c755f5fc 100644 --- a/packages/lib-core/src/types/store.ts +++ b/packages/lib-core/src/types/store.ts @@ -1,6 +1,6 @@ import type { AnyObject } from '@monorepo/common'; import type { LoadedExtension } from './extension'; -import type { PluginManifest, PendingPlugin, LoadedPlugin, FailedPlugin } from './plugin'; +import type { AnyPluginManifest, PendingPlugin, LoadedPlugin, FailedPlugin } from './plugin'; export enum PluginEventType { /** @@ -132,7 +132,8 @@ export type PluginStoreInterface = { /** * Start loading a plugin from the given manifest. * - * The plugin manifest can be provided as an object or referenced via URL. + * The manifest can be provided as an object (standard or local plugin manifest) + * or referenced by URL (to be loaded and validated as a standard plugin manifest). * * Depending on the plugin's current load status, this method works as follows: * - plugin is still loading - do nothing @@ -154,7 +155,7 @@ export type PluginStoreInterface = { * changes in a plugin's deployment, users should be prompted to reload the application * to ensure all plugin modules in use are up to date. */ - loadPlugin: (manifest: PluginManifest | string, forceReload?: boolean) => Promise; + loadPlugin: (manifest: AnyPluginManifest | string, forceReload?: boolean) => Promise; /** * Enable the given plugin(s). @@ -173,7 +174,7 @@ export type PluginStoreInterface = { /** * Get a module exposed by the given plugin. * - * The plugin is expected to be loaded by the `PluginStore`. + * The plugin is expected to be loaded from a standard plugin manifest. */ getExposedModule: ( pluginName: string, diff --git a/packages/lib-webpack/CHANGELOG.md b/packages/lib-webpack/CHANGELOG.md index cad785b8..58bd784d 100644 --- a/packages/lib-webpack/CHANGELOG.md +++ b/packages/lib-webpack/CHANGELOG.md @@ -1,7 +1,5 @@ # Changelog for `@openshift/dynamic-plugin-sdk-webpack` -> Changes prefixed with [!] refer to API breaking changes. - ## 4.1.0 - 2024-04-26 - Allow overriding core webpack module federation plugins used by DynamicRemotePlugin ([#259]) @@ -21,9 +19,9 @@ > to the generated plugin manifest. - Add `transformPluginManifest` option to `DynamicRemotePlugin` ([#236]) -- [!] Rename `sharedScope` to `sharedScopeName` in `moduleFederationSettings` ([#236]) +- BREAKING: Rename `sharedScope` to `sharedScopeName` in `moduleFederationSettings` ([#236]) - Validate values of plugin metadata `dependencies` object as semver ranges ([#239]) -- [!] Disallow empty strings as values of plugin metadata `dependencies` object ([#240]) +- BREAKING: Disallow empty strings as values of plugin metadata `dependencies` object ([#240]) - Fix bug in `DynamicRemotePlugin` where `buildHash` in plugin manifest is not generated properly ([#227]) ## 3.0.1 - 2023-04-13 @@ -34,14 +32,14 @@ ## 3.0.0 - 2023-03-02 - Add base URL for plugin assets to plugin manifest ([#206]) -- [!] Make `DynamicRemotePlugin` options `pluginMetadata` and `extensions` mandatory ([#207]) -- [!] Replace `DynamicRemotePlugin` option `moduleFederationLibraryType` with `moduleFederationSettings` ([#199]) +- BREAKING: Make `DynamicRemotePlugin` options `pluginMetadata` and `extensions` mandatory ([#207]) +- BREAKING: Replace `DynamicRemotePlugin` option `moduleFederationLibraryType` with `moduleFederationSettings` ([#199]) - Allow building plugins which do not provide any exposed modules ([#199]) ## 2.0.0 - 2023-01-23 - Support building plugins using webpack library type other than `jsonp` ([#182]) -- [!] Emit error when a separate runtime chunk is used with `jsonp` library type ([#182]) +- BREAKING: Emit error when a separate runtime chunk is used with `jsonp` library type ([#182]) - Allow customizing the filename of entry script and plugin manifest ([#182]) - Ensure that all APIs referenced through the package index are exported ([#184]) diff --git a/packages/sample-app/src/components/PageContent.cy.tsx b/packages/sample-app/src/components/PageContent.cy.tsx index 64bde3f5..ef1e2ee0 100644 --- a/packages/sample-app/src/components/PageContent.cy.tsx +++ b/packages/sample-app/src/components/PageContent.cy.tsx @@ -1,7 +1,7 @@ import type { LoadedExtension } from '@openshift/dynamic-plugin-sdk'; import { applyCodeRefSymbol } from '@openshift/dynamic-plugin-sdk'; import * as React from 'react'; -import { mockPluginManifest, mockPluginEntryModule } from '../test-mocks'; +import { mockLocalPluginManifest } from '../test-mocks'; import { RenderExtensions } from './PageContent'; describe('RenderExtensions', () => { @@ -11,52 +11,34 @@ describe('RenderExtensions', () => { it('Invokes all telemetry listener functions', () => { cy.getPluginStore().then((pluginStore) => { - const manifest = mockPluginManifest({ + const fooListener = cy.spy().as('fooListener'); + const barListener = cy.spy().as('barListener'); + + const manifest = mockLocalPluginManifest({ name: 'test', extensions: [ { type: 'core.telemetry/listener', properties: { - listener: { $codeRef: 'FooModule' }, + listener: applyCodeRefSymbol(() => Promise.resolve(fooListener)), }, }, { type: 'core.telemetry/listener', properties: { - listener: { $codeRef: 'BarModule.fizz' }, + listener: applyCodeRefSymbol(() => Promise.resolve(barListener)), }, }, ], }); - const fooListener = cy.spy().as('fooListener'); - const barListener = cy.spy().as('barListener'); - - const entryModule = mockPluginEntryModule({ - FooModule: { default: fooListener }, - BarModule: { fizz: barListener }, - }); - - const loadedExtensions: LoadedExtension[] = [ - { - type: 'core.telemetry/listener', - properties: { - listener: applyCodeRefSymbol(() => Promise.resolve(fooListener)), - }, - pluginName: 'test', - uid: 'test[0]', - }, - { - type: 'core.telemetry/listener', - properties: { - listener: applyCodeRefSymbol(() => Promise.resolve(barListener)), - }, - pluginName: 'test', - uid: 'test[1]', - }, - ]; + const loadedExtensions: LoadedExtension[] = manifest.extensions.map((e, index) => ({ + ...e, + pluginName: manifest.name, + uid: `${manifest.name}[${index}]`, + })); - pluginStore.addLoadedPlugin(manifest, entryModule, loadedExtensions); + pluginStore.addLoadedPlugin(manifest, loadedExtensions); pluginStore.enablePlugins(['test']); }); diff --git a/packages/sample-app/src/components/PluginInfoTable.cy.tsx b/packages/sample-app/src/components/PluginInfoTable.cy.tsx index 599f3a8d..89b9a411 100644 --- a/packages/sample-app/src/components/PluginInfoTable.cy.tsx +++ b/packages/sample-app/src/components/PluginInfoTable.cy.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import { mockPluginManifest, mockPluginEntryModule } from '../test-mocks'; +import { mockLocalPluginManifest } from '../test-mocks'; import PluginInfoTable from './PluginInfoTable'; describe('PluginInfoTable', () => { @@ -9,15 +9,9 @@ describe('PluginInfoTable', () => { it('Shows plugin runtime information', () => { cy.getPluginStore().then((pluginStore) => { - pluginStore.addPendingPlugin(mockPluginManifest({ name: 'test-3' })); - - pluginStore.addLoadedPlugin( - mockPluginManifest({ name: 'test-2' }), - mockPluginEntryModule(), - [], - ); - - pluginStore.addFailedPlugin(mockPluginManifest({ name: 'test-1' }), 'Test error message'); + pluginStore.addPendingPlugin(mockLocalPluginManifest({ name: 'test-3' })); + pluginStore.addLoadedPlugin(mockLocalPluginManifest({ name: 'test-2' }), []); + pluginStore.addFailedPlugin(mockLocalPluginManifest({ name: 'test-1' }), 'Boom!'); }); cy.get('[data-test-id="plugin-table"]') @@ -47,12 +41,7 @@ describe('PluginInfoTable', () => { it('Allows to manually disable a loaded plugin', () => { cy.getPluginStore().then((pluginStore) => { - pluginStore.addLoadedPlugin( - mockPluginManifest({ name: 'test' }), - mockPluginEntryModule(), - [], - ); - + pluginStore.addLoadedPlugin(mockLocalPluginManifest({ name: 'test' }), []); pluginStore.enablePlugins(['test']); }); diff --git a/packages/sample-app/src/components/PluginInfoTable.tsx b/packages/sample-app/src/components/PluginInfoTable.tsx index 4592fc36..ef58da09 100644 --- a/packages/sample-app/src/components/PluginInfoTable.tsx +++ b/packages/sample-app/src/components/PluginInfoTable.tsx @@ -30,8 +30,10 @@ const columnTooltips = { const getDropdownActions = (entry: PluginInfoEntry): IAction[] => [ { title: 'Log plugin manifest', - // eslint-disable-next-line no-console - onClick: () => console.log(`${entry.manifest.name} manifest`, entry.manifest), + onClick: () => { + // eslint-disable-next-line no-console + console.log(`${entry.manifest.name} manifest`, entry.manifest); + }, }, ]; diff --git a/packages/sample-app/src/test-mocks.ts b/packages/sample-app/src/test-mocks.ts index e8311017..72cfdd8d 100644 --- a/packages/sample-app/src/test-mocks.ts +++ b/packages/sample-app/src/test-mocks.ts @@ -1,30 +1,12 @@ -import type { AnyObject } from '@monorepo/common'; -import type { PluginManifest, PluginEntryModule } from '@openshift/dynamic-plugin-sdk'; -import { noop } from 'lodash'; +import type { LocalPluginManifest } from '@openshift/dynamic-plugin-sdk'; -export const mockPluginManifest = ({ +export const mockLocalPluginManifest = ({ name, version = '1.0.0', - baseURL = `http://localhost/${name}/${version}/`, extensions = [], - loadScripts = ['plugin-entry.js'], - registrationMethod = 'callback', -}: Pick & Partial): PluginManifest => ({ +}: Pick & Partial): LocalPluginManifest => ({ name, version, - baseURL, extensions, - loadScripts, - registrationMethod, + $local: true, }); - -export const mockPluginEntryModule = ( - pluginModules: { [moduleRequest: string]: AnyObject } = {}, - init: PluginEntryModule['init'] = noop, -): PluginEntryModule => { - return { - init, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - get: (moduleRequest: string) => Promise.resolve(() => pluginModules[moduleRequest] as any), - }; -}; diff --git a/reports/lib-core.api.md b/reports/lib-core.api.md index 80bf9d13..fcd34f08 100644 --- a/reports/lib-core.api.md +++ b/reports/lib-core.api.md @@ -9,6 +9,9 @@ import * as React_2 from 'react'; // @public export type AnyObject = Record; +// @public (undocumented) +export type AnyPluginManifest = PluginManifest | LocalPluginManifest; + // @public export const applyCodeRefSymbol: >(codeRef: T) => T; @@ -73,7 +76,7 @@ export type ExtractExtensionProperties = T extends Extension; + manifest: Readonly; errorMessage: string; errorCause?: unknown; }; @@ -88,6 +91,12 @@ export type FeatureFlags = { [flagName: string]: boolean; }; +// @public (undocumented) +export const isLocalPluginManifest: (manifest: AnyPluginManifest) => manifest is LocalPluginManifest; + +// @public (undocumented) +export const isStandardPluginManifest: (manifest: AnyPluginManifest) => manifest is PluginManifest; + // @public export type LoadedExtension = TExtension & { pluginName: string; @@ -96,9 +105,9 @@ export type LoadedExtension = TExtensi // @public export type LoadedPlugin = { - manifest: Readonly; + manifest: Readonly; loadedExtensions: Readonly; - entryModule: PluginEntryModule; + entryModule?: PluginEntryModule; enabled: boolean; disableReason?: string; }; @@ -108,6 +117,12 @@ export type LoadedPluginInfoEntry = { status: 'loaded'; } & Pick; +// @public +export type LocalPluginManifest = PluginRuntimeMetadata & { + extensions: Extension[]; + $local: true; +}; + // @public (undocumented) export type LogFunction = (message?: any, ...optionalParams: any[]) => void; @@ -131,7 +146,7 @@ export type Never = { // @public export type PendingPlugin = { - manifest: Readonly; + manifest: Readonly; }; // @public @@ -159,24 +174,24 @@ export type PluginInfoEntry = PendingPluginInfoEntry | LoadedPluginInfoEntry | F export class PluginLoader implements PluginLoaderInterface { constructor(options?: PluginLoaderOptions); // (undocumented) - loadPlugin(manifest: PluginManifest): Promise; + loadPlugin(manifest: AnyPluginManifest): Promise; // (undocumented) - loadPluginManifest(manifestURL: string): Promise; + loadPluginManifest(manifestURL: string): Promise; registerPluginEntryCallback(): void; // (undocumented) - transformPluginManifest(manifest: PluginManifest): PluginManifest; + transformPluginManifest(manifest: T): T; } // @public export type PluginLoaderInterface = { loadPluginManifest: (manifestURL: string) => Promise; - transformPluginManifest: (manifest: PluginManifest) => PluginManifest; - loadPlugin: (manifest: PluginManifest) => Promise; + transformPluginManifest: (manifest: T) => T; + loadPlugin: (manifest: AnyPluginManifest) => Promise; }; // @public (undocumented) export type PluginLoaderOptions = Partial<{ - canLoadPlugin: (manifest: PluginManifest, reload: boolean) => boolean; + canLoadPlugin: (manifest: AnyPluginManifest, reload: boolean) => boolean; canReloadScript: (manifest: PluginManifest, scriptName: string) => boolean; entryCallbackSettings: Partial<{ registerCallback: boolean; @@ -185,15 +200,15 @@ export type PluginLoaderOptions = Partial<{ fetchImpl: ResourceFetch; fixedPluginDependencyResolutions: Record; sharedScope: AnyObject; - transformPluginManifest: (manifest: PluginManifest) => PluginManifest; + transformPluginManifest: (manifest: T) => T; getPluginEntryModule: (manifest: PluginManifest) => PluginEntryModule | void; }>; // @public (undocumented) export type PluginLoadResult = { success: true; - entryModule: PluginEntryModule; loadedExtensions: LoadedExtension[]; + entryModule?: PluginEntryModule; } | { success: false; errorMessage: string; @@ -225,10 +240,11 @@ export type PluginRuntimeMetadata = { export class PluginStore implements PluginStoreInterface { constructor(options?: PluginStoreOptions & PluginStoreLoaderSettings); // (undocumented) - protected addFailedPlugin(manifest: PluginManifest, errorMessage: string, errorCause?: unknown): void; - protected addLoadedPlugin(manifest: PluginManifest, entryModule: PluginEntryModule, loadedExtensions: LoadedExtension[]): void; + protected addFailedPlugin(manifest: AnyPluginManifest, errorMessage: string, errorCause?: unknown): void; + // (undocumented) + protected addLoadedPlugin(manifest: AnyPluginManifest, loadedExtensions: LoadedExtension[], entryModule?: PluginEntryModule): void; // (undocumented) - protected addPendingPlugin(manifest: PluginManifest): void; + protected addPendingPlugin(manifest: AnyPluginManifest): void; // (undocumented) disablePlugins(pluginNames: string[], disableReason?: string): void; // (undocumented) @@ -244,7 +260,7 @@ export class PluginStore implements PluginStoreInterface { // (undocumented) getPluginInfo(): PluginInfoEntry[]; // (undocumented) - loadPlugin(manifest: PluginManifest | string, forceReload?: boolean): Promise; + loadPlugin(manifest: AnyPluginManifest | string, forceReload?: boolean): Promise; // (undocumented) readonly sdkVersion: string; // (undocumented) @@ -261,7 +277,7 @@ export type PluginStoreInterface = { getPluginInfo: () => PluginInfoEntry[]; getFeatureFlags: () => FeatureFlags; setFeatureFlags: (newFlags: FeatureFlags) => void; - loadPlugin: (manifest: PluginManifest | string, forceReload?: boolean) => Promise; + loadPlugin: (manifest: AnyPluginManifest | string, forceReload?: boolean) => Promise; enablePlugins: (pluginNames: string[]) => void; disablePlugins: (pluginNames: string[], disableReason?: string) => void; getExposedModule: (pluginName: string, moduleName: string) => Promise; @@ -303,11 +319,11 @@ export type ResourceFetch = (url: string, requestInit?: RequestInit, isK8sAPIReq // @public export class TestPluginStore extends PluginStore { // (undocumented) - addFailedPlugin(manifest: PluginManifest, errorMessage: string, errorCause?: unknown): void; + addFailedPlugin(...args: Parameters): void; // (undocumented) - addLoadedPlugin(manifest: PluginManifest, entryModule: PluginEntryModule, loadedExtensions: LoadedExtension[]): void; + addLoadedPlugin(...args: Parameters): void; // (undocumented) - addPendingPlugin(manifest: PluginManifest): void; + addPendingPlugin(...args: Parameters): void; } // @public