From 821ac7d9f166a58e435775873c10d1f9689a93ec Mon Sep 17 00:00:00 2001 From: Vojtech Szocs Date: Wed, 1 Jul 2026 14:54:36 +0000 Subject: [PATCH] Handle plugin manifest object as immutable (deep freeze) --- packages/common/src/utils/objects.test.ts | 58 +++++++++++++++++++- packages/common/src/utils/objects.ts | 18 ++++++ packages/lib-core/CHANGELOG.md | 4 ++ packages/lib-core/package.json | 8 ++- packages/lib-core/src/index.ts | 1 + packages/lib-core/src/runtime/PluginStore.ts | 17 ++++-- packages/lib-core/src/types/plugin.ts | 9 +-- packages/lib-webpack/CHANGELOG.md | 9 ++- packages/lib-webpack/package.json | 4 ++ reports/lib-core.api.md | 12 ++-- yarn.lock | 21 +++++++ 11 files changed, 144 insertions(+), 17 deletions(-) diff --git a/packages/common/src/utils/objects.test.ts b/packages/common/src/utils/objects.test.ts index 3e1ce338..a32391f3 100644 --- a/packages/common/src/utils/objects.test.ts +++ b/packages/common/src/utils/objects.test.ts @@ -1,4 +1,4 @@ -import { applyDefaults, cloneDeepOnlyCloneableValues } from './objects'; +import { applyDefaults, cloneDeepOnlyCloneableValues, freezeDeep } from './objects'; describe('applyDefaults', () => { it('should recursively assign defaults for properties which are undefined', () => { @@ -74,3 +74,59 @@ describe('cloneDeepOnlyCloneableValues', () => { expect(clone[3]).toBe(source[3]); }); }); + +describe('freezeDeep', () => { + it('should return the same object reference', () => { + const obj = { foo: 1 }; + + expect(freezeDeep(obj)).toBe(obj); + }); + + it('should freeze the top-level object', () => { + const obj = { foo: 1 }; + freezeDeep(obj); + + expect(Object.isFrozen(obj)).toBe(true); + }); + + it('should freeze nested objects', () => { + const obj = { foo: { bar: { baz: true } } }; + freezeDeep(obj); + + expect(Object.isFrozen(obj.foo)).toBe(true); + expect(Object.isFrozen(obj.foo.bar)).toBe(true); + }); + + it('should freeze nested arrays', () => { + const obj = { foo: [1, 2, 3] }; + freezeDeep(obj); + + expect(Object.isFrozen(obj.foo)).toBe(true); + }); + + it('should freeze objects nested within arrays', () => { + const obj = { items: [{ value: 'test' }] }; + freezeDeep(obj); + + expect(Object.isFrozen(obj.items[0])).toBe(true); + }); + + it('should handle non-enumerable properties', () => { + const hidden = { secret: true }; + const obj = {}; + + Object.defineProperty(obj, 'hidden', { value: hidden, enumerable: false }); + freezeDeep(obj); + + expect(Object.isFrozen(hidden)).toBe(true); + }); + + it('should not fail on objects with circular references', () => { + const obj: Record = { foo: 1 }; + obj.self = obj; + freezeDeep(obj); + + expect(Object.isFrozen(obj)).toBe(true); + expect(Object.isFrozen(obj.self)).toBe(true); + }); +}); diff --git a/packages/common/src/utils/objects.ts b/packages/common/src/utils/objects.ts index 309317bd..2cd7e551 100644 --- a/packages/common/src/utils/objects.ts +++ b/packages/common/src/utils/objects.ts @@ -1,4 +1,5 @@ import { cloneDeepWith, defaultsDeep, forOwn, isPlainObject } from 'lodash'; +import type { ReadonlyDeep } from 'type-fest'; import type { AnyObject } from '../types/common'; /** @@ -49,3 +50,20 @@ export const cloneDeepOnlyCloneableValues = (obj: TObject): TObject => ? value : undefined; }); + +/** + * Recursively freeze `obj` using `Object.freeze` and return the frozen object. + */ +export const freezeDeep = (obj: T): ReadonlyDeep => { + Object.freeze(obj); + + Object.getOwnPropertyNames(obj).forEach((key) => { + const value = (obj as AnyObject)[key]; + + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + freezeDeep(value); + } + }); + + return obj as ReadonlyDeep; +}; diff --git a/packages/lib-core/CHANGELOG.md b/packages/lib-core/CHANGELOG.md index 6bd9a60c..1bd19d81 100644 --- a/packages/lib-core/CHANGELOG.md +++ b/packages/lib-core/CHANGELOG.md @@ -3,7 +3,10 @@ ## 9.0.0 - TBD - BREAKING: Modify `useResolvedExtensions` hook to accept `extensions` array as a parameter ([#313]) +- Deep freeze plugin manifest object when adding plugins to the `PluginStore` ([#325]) +- Add optional peer dependency on `type-fest` ([#325]) - Expose `visitDeep` utility function ([#313]) +- Expose `freezeDeep` utility function ([#325]) ## 8.2.0 - 2026-03-19 @@ -144,3 +147,4 @@ [#309]: https://github.com/openshift/dynamic-plugin-sdk/pull/309 [#310]: https://github.com/openshift/dynamic-plugin-sdk/pull/310 [#313]: https://github.com/openshift/dynamic-plugin-sdk/pull/313 +[#325]: https://github.com/openshift/dynamic-plugin-sdk/pull/325 diff --git a/packages/lib-core/package.json b/packages/lib-core/package.json index 2c05cdd6..cca276ce 100644 --- a/packages/lib-core/package.json +++ b/packages/lib-core/package.json @@ -28,7 +28,13 @@ "api-extractor-local": "yarn run api-extractor --local" }, "peerDependencies": { - "react": "^18 || ^19" + "react": "^18 || ^19", + "type-fest": "^2.18.0" + }, + "peerDependenciesMeta": { + "type-fest": { + "optional": true + } }, "dependencies": { "lodash": "^4.17.23", diff --git a/packages/lib-core/src/index.ts b/packages/lib-core/src/index.ts index ebeacc2f..380ba3fb 100644 --- a/packages/lib-core/src/index.ts +++ b/packages/lib-core/src/index.ts @@ -17,6 +17,7 @@ export { CustomError, EitherNotBoth, EitherOrNone, + freezeDeep, LogFunction, Logger, Never, diff --git a/packages/lib-core/src/runtime/PluginStore.ts b/packages/lib-core/src/runtime/PluginStore.ts index ed97631a..85b89f72 100644 --- a/packages/lib-core/src/runtime/PluginStore.ts +++ b/packages/lib-core/src/runtime/PluginStore.ts @@ -1,5 +1,5 @@ import type { AnyObject, EitherNotBoth } from '@monorepo/common'; -import { consoleLogger, ErrorWithCause } from '@monorepo/common'; +import { consoleLogger, freezeDeep, ErrorWithCause } from '@monorepo/common'; import { compact, isEqual, noop, pickBy } from 'lodash'; import { version as sdkVersion } from '../../package.json'; import type { LoadedExtension } from '../types/extension'; @@ -301,7 +301,10 @@ export class PluginStore implements PluginStoreInterface { protected addPendingPlugin(manifest: PluginManifest) { const pluginName = manifest.name; - const pendingPlugin: PendingPlugin = { manifest }; + + const pendingPlugin: PendingPlugin = { + manifest: freezeDeep(manifest), + }; this.pendingPlugins.set(pluginName, pendingPlugin); this.loadedPlugins.delete(pluginName); @@ -324,8 +327,7 @@ export class PluginStore implements PluginStoreInterface { const pluginName = manifest.name; const loadedPlugin: LoadedPlugin = { - // TODO(vojtech): use deepFreeze on the manifest and type it as DeepReadonly - manifest: Object.freeze(manifest), + manifest: freezeDeep(manifest), loadedExtensions: loadedExtensions.map((e) => Object.freeze(e)), entryModule, enabled: false, @@ -341,7 +343,12 @@ export class PluginStore implements PluginStoreInterface { protected addFailedPlugin(manifest: PluginManifest, errorMessage: string, errorCause?: unknown) { const pluginName = manifest.name; - const failedPlugin: FailedPlugin = { manifest, errorMessage, errorCause }; + + const failedPlugin: FailedPlugin = { + manifest: freezeDeep(manifest), + errorMessage, + errorCause, + }; this.pendingPlugins.delete(pluginName); this.loadedPlugins.delete(pluginName); diff --git a/packages/lib-core/src/types/plugin.ts b/packages/lib-core/src/types/plugin.ts index 071bb5b9..b2b1d0b1 100644 --- a/packages/lib-core/src/types/plugin.ts +++ b/packages/lib-core/src/types/plugin.ts @@ -1,3 +1,4 @@ +import type { ReadonlyDeep } from 'type-fest'; import type { Extension, LoadedExtension } from './extension'; import type { PluginEntryModule } from './runtime'; @@ -90,15 +91,15 @@ export type PluginManifest = RemotePluginManifest | LocalPluginManifest; * Internal entry on a plugin in `pending` state. */ export type PendingPlugin = { - manifest: Readonly; + manifest: ReadonlyDeep; }; /** * Internal entry on a plugin in `loaded` state. */ export type LoadedPlugin = { - manifest: Readonly; - loadedExtensions: Readonly; + manifest: ReadonlyDeep; + loadedExtensions: Readonly[]>; entryModule?: PluginEntryModule; enabled: boolean; disableReason?: string; @@ -108,7 +109,7 @@ export type LoadedPlugin = { * Internal entry on a plugin in `failed` state. */ export type FailedPlugin = { - manifest: Readonly; + manifest: ReadonlyDeep; errorMessage: string; errorCause?: unknown; }; diff --git a/packages/lib-webpack/CHANGELOG.md b/packages/lib-webpack/CHANGELOG.md index b6c92689..6cda6ce1 100644 --- a/packages/lib-webpack/CHANGELOG.md +++ b/packages/lib-webpack/CHANGELOG.md @@ -1,9 +1,13 @@ # Changelog for `@openshift/dynamic-plugin-sdk-webpack` +## 5.3.0 - TBD + +- Add optional peer dependency on `type-fest` ([#325]) + ## 5.2.0 - 2026-06-11 -- Add rspack compatibility: `DynamicRemotePlugin` now works with both webpack and rspack bundlers. Both - `@rspack/core` and `webpack` are now optional peer dependencies. ([#285]) +- Update `DynamicRemotePlugin` to work with both webpack and rspack module bundlers ([#285]) +- `@rspack/core` and `webpack` peer dependencies are now optional ([#285]) ## 5.1.1 - 2026-04-16 @@ -86,3 +90,4 @@ [#289]: https://github.com/openshift/dynamic-plugin-sdk/pull/289 [#296]: https://github.com/openshift/dynamic-plugin-sdk/pull/296 [#314]: https://github.com/openshift/dynamic-plugin-sdk/pull/314 +[#325]: https://github.com/openshift/dynamic-plugin-sdk/pull/325 diff --git a/packages/lib-webpack/package.json b/packages/lib-webpack/package.json index f7e27a70..72b60d21 100644 --- a/packages/lib-webpack/package.json +++ b/packages/lib-webpack/package.json @@ -27,12 +27,16 @@ }, "peerDependencies": { "@rspack/core": "^2.0.8", + "type-fest": "^2.18.0", "webpack": "^5.100.0" }, "peerDependenciesMeta": { "@rspack/core": { "optional": true }, + "type-fest": { + "optional": true + }, "webpack": { "optional": true } diff --git a/reports/lib-core.api.md b/reports/lib-core.api.md index 02fcdcb7..e0844230 100644 --- a/reports/lib-core.api.md +++ b/reports/lib-core.api.md @@ -6,6 +6,7 @@ import type { FC } from 'react'; import type { PropsWithChildren } from 'react'; +import type { ReadonlyDeep } from 'type-fest'; // @public export type AnyObject = Record; @@ -77,7 +78,7 @@ export type ExtractExtensionProperties = T extends Extension; + manifest: ReadonlyDeep; errorMessage: string; errorCause?: unknown; }; @@ -95,6 +96,9 @@ export type FeatureFlags = { // @public export type FeatureFlagValue = boolean | undefined; +// @public +export const freezeDeep: (obj: T) => ReadonlyDeep; + // @public export const isCodeRef: (obj: unknown) => obj is CodeRef; @@ -112,8 +116,8 @@ export type LoadedExtension = TExtensi // @public export type LoadedPlugin = { - manifest: Readonly; - loadedExtensions: Readonly; + manifest: ReadonlyDeep; + loadedExtensions: Readonly[]>; entryModule?: PluginEntryModule; enabled: boolean; disableReason?: string; @@ -159,7 +163,7 @@ export const parseEncodedCodeRef: (ref: EncodedCodeRef) => { // @public export type PendingPlugin = { - manifest: Readonly; + manifest: ReadonlyDeep; }; // @public diff --git a/yarn.lock b/yarn.lock index 4237e798..279aab65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1144,10 +1144,13 @@ __metadata: yup: "npm:^1.7.1" peerDependencies: "@rspack/core": ^2.0.8 + type-fest: ^2.18.0 webpack: ^5.100.0 peerDependenciesMeta: "@rspack/core": optional: true + type-fest: + optional: true webpack: optional: true languageName: node @@ -1162,10 +1165,13 @@ __metadata: yup: "npm:^1.7.1" peerDependencies: "@rspack/core": ^2.0.8 + type-fest: ^2.18.0 webpack: ^5.100.0 peerDependenciesMeta: "@rspack/core": optional: true + type-fest: + optional: true webpack: optional: true languageName: node @@ -1180,10 +1186,13 @@ __metadata: yup: "npm:^1.7.1" peerDependencies: "@rspack/core": ^2.0.8 + type-fest: ^2.18.0 webpack: ^5.100.0 peerDependenciesMeta: "@rspack/core": optional: true + type-fest: + optional: true webpack: optional: true languageName: unknown @@ -1199,6 +1208,10 @@ __metadata: yup: "npm:^1.7.1" peerDependencies: react: ^18 || ^19 + type-fest: ^2.18.0 + peerDependenciesMeta: + type-fest: + optional: true languageName: node linkType: soft @@ -1212,6 +1225,10 @@ __metadata: yup: "npm:^1.7.1" peerDependencies: react: ^18 || ^19 + type-fest: ^2.18.0 + peerDependenciesMeta: + type-fest: + optional: true languageName: node linkType: soft @@ -1225,6 +1242,10 @@ __metadata: yup: "npm:^1.7.1" peerDependencies: react: ^18 || ^19 + type-fest: ^2.18.0 + peerDependenciesMeta: + type-fest: + optional: true languageName: unknown linkType: soft