From c174965708ff33f6bdf036bf2883f21daec3acea Mon Sep 17 00:00:00 2001 From: Janne Savolainen Date: Tue, 28 Feb 2023 15:37:07 +0200 Subject: [PATCH] Introduce package for Features (#7242) Signed-off-by: Janne Savolainen --- package-lock.json | 11 + .../technical-features/feature-core/README.md | 41 +++ .../technical-features/feature-core/index.ts | 3 + .../feature-core/jest.config.js | 2 + .../feature-core/package.json | 30 ++ .../feature-core/src/deregister-feature.ts | 82 +++++ .../src/feature-context-map-injectable.ts | 27 ++ .../src/feature-dependencies.test.ts | 281 ++++++++++++++++++ .../feature-core/src/feature.ts | 12 + .../feature-core/src/register-feature.ts | 90 ++++++ .../src/registration-of-feature.test.ts | 147 +++++++++ .../feature-core/tsconfig.json | 3 + .../feature-core/webpack.config.js | 1 + 13 files changed, 730 insertions(+) create mode 100644 packages/technical-features/feature-core/README.md create mode 100644 packages/technical-features/feature-core/index.ts create mode 100644 packages/technical-features/feature-core/jest.config.js create mode 100644 packages/technical-features/feature-core/package.json create mode 100644 packages/technical-features/feature-core/src/deregister-feature.ts create mode 100644 packages/technical-features/feature-core/src/feature-context-map-injectable.ts create mode 100644 packages/technical-features/feature-core/src/feature-dependencies.test.ts create mode 100644 packages/technical-features/feature-core/src/feature.ts create mode 100644 packages/technical-features/feature-core/src/register-feature.ts create mode 100644 packages/technical-features/feature-core/src/registration-of-feature.test.ts create mode 100644 packages/technical-features/feature-core/tsconfig.json create mode 100644 packages/technical-features/feature-core/webpack.config.js diff --git a/package-lock.json b/package-lock.json index 2cfc549628..43e976a625 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3301,6 +3301,10 @@ "resolved": "packages/extension-api", "link": true }, + "node_modules/@k8slens/feature-core": { + "resolved": "packages/technical-features/feature-core", + "link": true + }, "node_modules/@k8slens/generate-tray-icons": { "resolved": "packages/generate-tray-icons", "link": true @@ -34811,6 +34815,13 @@ "@ogre-tools/injectable": "^15.1.1", "lodash": "^4.17.15" } + }, + "packages/technical-features/feature-core": { + "version": "0.0.1", + "license": "MIT", + "peerDependencies": { + "@ogre-tools/injectable": "^15.1.1" + } } } } diff --git a/packages/technical-features/feature-core/README.md b/packages/technical-features/feature-core/README.md new file mode 100644 index 0000000000..272893dc7b --- /dev/null +++ b/packages/technical-features/feature-core/README.md @@ -0,0 +1,41 @@ +# @k8slens/feature-core + +Feature is set of injectables that are registered and deregistered simultaneously. + +## Install +```bash +$ npm install @k8slens/feature-core +``` + +## Usage + +```typescript +import { createContainer } from "@ogre-tools/injectable" +import { getFeature, registerFeature, deregisterFeature } from "@k8slens/feature-core" + +// Notice that this Feature is usually exported from another NPM package. +const someFeature = getFeature({ + id: "some-feature", + + register: (di) => { + di.register(someInjectable, someOtherInjectable); + }, + + // Feature dependencies are automatically registered and + // deregistered when necessary. + dependencies: [someOtherFeature] +}); + +const di = createContainer("some-container"); + +registerFeature(di, someFeature); + +// Or perhaps you want to deregister? +deregisterFeature(di, someFeature); +``` + +## Need to know + +#### NPM packages exporting a Feature +- Prefer `peerDependencies` since they are installed from the application and are not allowed to be in the built bundle. +- Prefer exporting `injectionToken` instead of `injectable` for not allowing other features to access technical details like the `injectable` diff --git a/packages/technical-features/feature-core/index.ts b/packages/technical-features/feature-core/index.ts new file mode 100644 index 0000000000..13f6306665 --- /dev/null +++ b/packages/technical-features/feature-core/index.ts @@ -0,0 +1,3 @@ +export { getFeature } from "./src/feature"; +export { registerFeature } from "./src/register-feature"; +export type { Feature, GetFeatureArgs } from "./src/feature"; diff --git a/packages/technical-features/feature-core/jest.config.js b/packages/technical-features/feature-core/jest.config.js new file mode 100644 index 0000000000..23be80353b --- /dev/null +++ b/packages/technical-features/feature-core/jest.config.js @@ -0,0 +1,2 @@ +module.exports = + require("@k8slens/jest").monorepoPackageConfig(__dirname).configForReact; diff --git a/packages/technical-features/feature-core/package.json b/packages/technical-features/feature-core/package.json new file mode 100644 index 0000000000..fa4f821fef --- /dev/null +++ b/packages/technical-features/feature-core/package.json @@ -0,0 +1,30 @@ +{ + "name": "@k8slens/feature-core", + "private": false, + "version": "0.0.1", + "description": "Code that is common to all Features and those registering them.", + "type": "commonjs", + "files": [ + "dist" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/lensapp/lens.git" + }, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "author": { + "name": "OpenLens Authors", + "email": "info@k8slens.dev" + }, + "license": "MIT", + "homepage": "https://github.com/lensapp/lens", + "scripts": { + "build": "webpack", + "dev": "webpack --mode=development --watch", + "test": "jest --coverage --runInBand" + }, + "peerDependencies": { + "@ogre-tools/injectable": "^15.1.1" + } +} diff --git a/packages/technical-features/feature-core/src/deregister-feature.ts b/packages/technical-features/feature-core/src/deregister-feature.ts new file mode 100644 index 0000000000..3d77759659 --- /dev/null +++ b/packages/technical-features/feature-core/src/deregister-feature.ts @@ -0,0 +1,82 @@ +import type { DiContainer } from "@ogre-tools/injectable"; +import type { Feature } from "./feature"; +import { featureContextMapInjectable } from "./feature-context-map-injectable"; + +export const deregisterFeature = (di: DiContainer, ...features: Feature[]) => { + features.forEach((feature) => { + deregisterFeatureRecursed(di, feature); + }); +}; + +const deregisterFeatureRecursed = ( + di: DiContainer, + feature: Feature, + dependedBy?: Feature +) => { + const featureContextMap = di.inject(featureContextMapInjectable); + + const featureContext = featureContextMap.get(feature); + + if (!featureContext) { + throw new Error( + `Tried to deregister feature "${feature.id}", but it was not registered.` + ); + } + + featureContext.numberOfRegistrations--; + + const getDependingFeatures = getDependingFeaturesFor(featureContextMap); + + const dependingFeatures = getDependingFeatures(feature); + + if (!dependedBy && dependingFeatures.length) { + throw new Error( + `Tried to deregister Feature "${ + feature.id + }", but it is the dependency of Features "${dependingFeatures.join( + ", " + )}"` + ); + } + + if (dependedBy) { + const oldNumberOfDependents = featureContext.dependedBy.get(dependedBy)!; + const newNumberOfDependants = oldNumberOfDependents - 1; + featureContext.dependedBy.set(dependedBy, newNumberOfDependants); + + if (newNumberOfDependants === 0) { + featureContext.dependedBy.delete(dependedBy); + } + } + + if (featureContext.numberOfRegistrations === 0) { + featureContextMap.delete(feature); + + featureContext.deregister(); + } + + feature.dependencies?.forEach((dependency) => { + deregisterFeatureRecursed(di, dependency, feature); + }); +}; + +const getDependingFeaturesFor = ( + featureContextMap: Map }> +) => { + const getDependingFeaturesForRecursion = ( + feature: Feature, + atRoot = true + ): string[] => { + const context = featureContextMap.get(feature); + + if (context?.dependedBy.size) { + return [...context!.dependedBy.entries()].flatMap(([dependant]) => + getDependingFeaturesForRecursion(dependant, false) + ); + } + + return atRoot ? [] : [feature.id]; + }; + + return getDependingFeaturesForRecursion; +}; diff --git a/packages/technical-features/feature-core/src/feature-context-map-injectable.ts b/packages/technical-features/feature-core/src/feature-context-map-injectable.ts new file mode 100644 index 0000000000..8c34524fee --- /dev/null +++ b/packages/technical-features/feature-core/src/feature-context-map-injectable.ts @@ -0,0 +1,27 @@ +import { getInjectable, getInjectionToken } from "@ogre-tools/injectable"; +import type { Feature } from "./feature"; + +export type FeatureContextMap = Map< + Feature, + { + register: () => void; + deregister: () => void; + dependedBy: Map; + numberOfRegistrations: number; + } +>; + +export const featureContextMapInjectionToken = + getInjectionToken({ + id: "feature-context-map-injection-token", + }); + +const featureContextMapInjectable = getInjectable({ + id: "feature-store", + + instantiate: (): FeatureContextMap => new Map(), + + injectionToken: featureContextMapInjectionToken, +}); + +export { featureContextMapInjectable }; diff --git a/packages/technical-features/feature-core/src/feature-dependencies.test.ts b/packages/technical-features/feature-core/src/feature-dependencies.test.ts new file mode 100644 index 0000000000..cdb7267419 --- /dev/null +++ b/packages/technical-features/feature-core/src/feature-dependencies.test.ts @@ -0,0 +1,281 @@ +import { + createContainer, + DiContainer, + getInjectable, + Injectable, +} from "@ogre-tools/injectable"; + +import type { Feature } from "./feature"; +import { registerFeature } from "./register-feature"; +import { deregisterFeature } from "./deregister-feature"; +import { getFeature } from "./feature" ; + +describe("feature-dependencies", () => { + describe("given a parent Feature with another Features as dependency", () => { + let di: DiContainer; + let someInjectable: Injectable; + let someInjectableInDependencyFeature: Injectable; + let someParentFeature: Feature; + let someDependencyFeature: Feature; + + beforeEach(() => { + di = createContainer("irrelevant"); + + someInjectable = getInjectable({ + id: "some-injectable-2", + instantiate: () => "some-instance", + }); + + someInjectableInDependencyFeature = getInjectable({ + id: "some-injectable", + instantiate: () => "some-instance-2", + }); + + someDependencyFeature = getFeature({ + id: "some-dependency-feature", + register: (di) => di.register(someInjectableInDependencyFeature), + }); + + someParentFeature = getFeature({ + id: "some-feature", + register: (di) => di.register(someInjectable), + dependencies: [someDependencyFeature], + }); + + registerFeature(di, someParentFeature); + }); + + it("when an injectable from the dependency Feature is injected, does so", () => { + const actual = di.inject(someInjectableInDependencyFeature); + + expect(actual).toBe("some-instance-2"); + }); + + it("when the dependency Feature is deregistered, throws", () => { + expect(() => { + deregisterFeature(di, someDependencyFeature); + }).toThrow( + 'Tried to deregister Feature "some-dependency-feature", but it is the dependency of Features "some-feature"' + ); + }); + + it("given the parent Feature is already deregistered, when also the dependency Feature is deregistered, throws", () => { + deregisterFeature(di, someParentFeature); + + expect(() => { + deregisterFeature(di, someDependencyFeature); + }).toThrow( + 'Tried to deregister feature "some-dependency-feature", but it was not registered.' + ); + }); + + it("given the parent Feature is deregistered, when injecting an injectable from the dependency Feature, throws", () => { + deregisterFeature(di, someParentFeature); + + expect(() => { + di.inject(someInjectableInDependencyFeature); + }).toThrow( + 'Tried to inject non-registered injectable "irrelevant" -> "some-injectable".' + ); + }); + }); + + describe("given a first Feature is registered, when second Feature using the first Feature as dependency gets registered", () => { + let di: DiContainer; + let someInjectable: Injectable; + let someFeature2: Feature; + let someFeature1: Feature; + + beforeEach(() => { + di = createContainer("irrelevant"); + + someInjectable = getInjectable({ + id: "some-injectable", + instantiate: () => "some-instance", + }); + + someFeature1 = getFeature({ + id: "some-feature-1", + register: (di) => di.register(someInjectable), + }); + + someFeature2 = getFeature({ + id: "some-feature-2", + register: () => {}, + dependencies: [someFeature1], + }); + + registerFeature(di, someFeature1, someFeature2); + }); + + it("when the first Feature is deregistered, throws", () => { + expect(() => { + deregisterFeature(di, someFeature1); + }).toThrow( + 'Tried to deregister Feature "some-feature-1", but it is the dependency of Features "some-feature-2"' + ); + }); + + it("given the second Feature is deregistered, when injecting an injectable from the first Feature, still does so", () => { + deregisterFeature(di, someFeature2); + + const actual = di.inject(someInjectable); + + expect(actual).toBe("some-instance"); + }); + }); + + describe("given parent Features with a shared Feature as dependency", () => { + let di: DiContainer; + let someInjectableInDependencyFeature: Injectable; + let someFeature1: Feature; + let someFeature2: Feature; + let someSharedDependencyFeature: Feature; + + beforeEach(() => { + di = createContainer("irrelevant"); + + someInjectableInDependencyFeature = getInjectable({ + id: "some-injectable-in-dependency-feature", + instantiate: () => "some-instance", + }); + + someSharedDependencyFeature = getFeature({ + id: "some-dependency-feature", + register: (di) => di.register(someInjectableInDependencyFeature), + }); + + const someFeatureForAdditionalHierarchy = getFeature({ + id: "some-feature-for-additional-hierarchy", + register: () => {}, + dependencies: [someSharedDependencyFeature], + }); + + someFeature1 = getFeature({ + id: "some-feature-1", + register: () => {}, + dependencies: [someFeatureForAdditionalHierarchy], + }); + + someFeature2 = getFeature({ + id: "some-feature-2", + register: () => {}, + dependencies: [someFeatureForAdditionalHierarchy], + }); + + registerFeature(di, someFeature1, someFeature2); + }); + + it("when the shared Feature is deregistered, throws", () => { + expect(() => { + deregisterFeature(di, someSharedDependencyFeature); + }).toThrow( + 'Tried to deregister Feature "some-dependency-feature", but it is the dependency of Features "some-feature-1, some-feature-2"' + ); + }); + + it("given only part of the parent Features get deregistered, when injecting an injectable from the shared Feature, does so", () => { + deregisterFeature(di, someFeature1); + + const actual = di.inject(someInjectableInDependencyFeature); + + expect(actual).toBe("some-instance"); + }); + + it("given all of the parent Features get deregistered, when injecting an injectable from the shared Feature, throws", () => { + deregisterFeature(di, someFeature1, someFeature2); + + expect(() => { + di.inject(someInjectableInDependencyFeature); + }).toThrow( + 'Tried to inject non-registered injectable "irrelevant" -> "some-injectable-in-dependency-feature".' + ); + }); + }); + + describe("given parent Features with a shared Feature as dependency and registered, when the shared Feature gets registered again", () => { + let di: DiContainer; + let someInjectableInDependencyFeature: Injectable; + let someFeature1: Feature; + let someFeature2: Feature; + let someSharedDependencyFeature: Feature; + + beforeEach(() => { + di = createContainer("irrelevant"); + + someInjectableInDependencyFeature = getInjectable({ + id: "some-injectable-in-dependency-feature", + instantiate: () => "some-instance", + }); + + someSharedDependencyFeature = getFeature({ + id: "some-dependency-feature", + register: (di) => di.register(someInjectableInDependencyFeature), + }); + + const someFeatureForAdditionalHierarchy = getFeature({ + id: "some-feature-for-additional-hierarchy", + register: () => {}, + dependencies: [someSharedDependencyFeature], + }); + + someFeature1 = getFeature({ + id: "some-feature-1", + register: () => {}, + dependencies: [someFeatureForAdditionalHierarchy], + }); + + someFeature2 = getFeature({ + id: "some-feature-2", + register: () => {}, + dependencies: [someFeatureForAdditionalHierarchy], + }); + + registerFeature( + di, + someFeature1, + someFeature2, + someSharedDependencyFeature + ); + }); + + it("when the shared Feature is deregistered, throws", () => { + expect(() => { + deregisterFeature(di, someSharedDependencyFeature); + }).toThrow( + 'Tried to deregister Feature "some-dependency-feature", but it is the dependency of Features "some-feature-1, some-feature-2"' + ); + }); + + it("given only part of the parent Features get deregistered, when injecting an injectable from the shared Feature, does so", () => { + deregisterFeature(di, someFeature1); + + const actual = di.inject(someInjectableInDependencyFeature); + + expect(actual).toBe("some-instance"); + }); + + it("given all of the parent Features get deregistered, when injecting an injectable from the shared Feature, still does so", () => { + deregisterFeature(di, someFeature1, someFeature2); + + const actual = di.inject(someInjectableInDependencyFeature); + + expect(actual).toBe("some-instance"); + }); + + it("given all of the Features get deregistered, when injecting an injectable from the shared Feature, throws", () => { + deregisterFeature( + di, + someFeature1, + someFeature2, + someSharedDependencyFeature + ); + + expect(() => { + di.inject(someInjectableInDependencyFeature); + }).toThrow( + 'Tried to inject non-registered injectable "irrelevant" -> "some-injectable-in-dependency-feature".' + ); + }); + }); +}); diff --git a/packages/technical-features/feature-core/src/feature.ts b/packages/technical-features/feature-core/src/feature.ts new file mode 100644 index 0000000000..6a8084c92a --- /dev/null +++ b/packages/technical-features/feature-core/src/feature.ts @@ -0,0 +1,12 @@ +import type { DiContainerForInjection } from "@ogre-tools/injectable"; + +export interface Feature { + id: string; + register: (di: DiContainerForInjection) => void; + dependencies?: Feature[]; +} + +export interface GetFeatureArgs extends Feature {} + +export const getFeature = (getFeatureArgs: GetFeatureArgs): Feature => + getFeatureArgs; diff --git a/packages/technical-features/feature-core/src/register-feature.ts b/packages/technical-features/feature-core/src/register-feature.ts new file mode 100644 index 0000000000..391bd60fa3 --- /dev/null +++ b/packages/technical-features/feature-core/src/register-feature.ts @@ -0,0 +1,90 @@ +import type { DiContainer } from "@ogre-tools/injectable"; +import { getInjectable } from "@ogre-tools/injectable"; +import type { Feature } from "./feature"; +import { + featureContextMapInjectable, + featureContextMapInjectionToken, +} from "./feature-context-map-injectable"; + +export const registerFeature = (di: DiContainer, ...features: Feature[]) => { + features.forEach((feature) => { + registerFeatureRecursed(di, feature); + }); +}; + +const registerFeatureRecursed = ( + di: DiContainer, + feature: Feature, + dependedBy?: Feature +) => { + const featureContextMaps = di.injectMany(featureContextMapInjectionToken); + + if (featureContextMaps.length === 0) { + di.register(featureContextMapInjectable); + } + + const featureContextMap = di.inject(featureContextMapInjectable); + + const existingFeatureContext = featureContextMap.get(feature); + if ( + !dependedBy && + existingFeatureContext && + existingFeatureContext.dependedBy.size === 0 + ) { + throw new Error( + `Tried to register feature "${feature.id}", but it was already registered.` + ); + } + + const featureContext = + existingFeatureContext || createFeatureContext(feature, di); + + featureContext.numberOfRegistrations++; + + if (dependedBy) { + const oldNumberOfDependents = + featureContext.dependedBy.get(dependedBy) || 0; + + const newNumberOfDependants = oldNumberOfDependents + 1; + featureContext.dependedBy.set(dependedBy, newNumberOfDependants); + } + + if (!existingFeatureContext) { + featureContext.register(); + } + + feature.dependencies?.forEach((dependency) => { + registerFeatureRecursed(di, dependency, feature); + }); +}; + +const createFeatureContext = (feature: Feature, di: DiContainer) => { + const featureContextInjectable = getInjectable({ + id: feature.id, + + instantiate: (diForContextOfFeature) => ({ + register: () => { + feature.register(diForContextOfFeature); + }, + + deregister: () => { + diForContextOfFeature.deregister(featureContextInjectable); + }, + + dependedBy: new Map(), + + numberOfRegistrations: 0, + }), + + scope: true, + }); + + di.register(featureContextInjectable); + + const featureContextMap = di.inject(featureContextMapInjectable); + const featureContext = di.inject(featureContextInjectable); + + featureContextMap.set(feature, featureContext); + + return featureContext; +}; diff --git a/packages/technical-features/feature-core/src/registration-of-feature.test.ts b/packages/technical-features/feature-core/src/registration-of-feature.test.ts new file mode 100644 index 0000000000..ee7d6d7c86 --- /dev/null +++ b/packages/technical-features/feature-core/src/registration-of-feature.test.ts @@ -0,0 +1,147 @@ +import { registerFeature } from "./register-feature"; +import { + createContainer, + DiContainer, + getInjectable, + Injectable, +} from "@ogre-tools/injectable"; +import type { Feature } from "./feature"; +import { getFeature } from "./feature"; +import { deregisterFeature } from "./deregister-feature"; + +describe("register-feature", () => { + describe("given di-container and a Features with injectables, and given Features are registered", () => { + let di: DiContainer; + let someInjectable: Injectable; + let someInjectable2: Injectable; + let someFeature: Feature; + let someFeature2: Feature; + let instance: string; + + beforeEach(() => { + di = createContainer("irrelevant"); + + someInjectable = getInjectable({ + id: "some-injectable", + instantiate: () => "some-instance", + }); + + someInjectable2 = getInjectable({ + id: "some-injectable-2", + instantiate: () => "some-instance-2", + }); + + someFeature = getFeature({ + id: "some-feature-1", + register: (di) => di.register(someInjectable), + }); + + someFeature2 = getFeature({ + id: "some-feature-2", + register: (di) => di.register(someInjectable2), + }); + + registerFeature(di, someFeature); + registerFeature(di, someFeature2); + }); + + it("when an injectable is injected, does so", () => { + instance = di.inject(someInjectable); + + expect(instance).toBe("some-instance"); + }); + + describe("given a Feature is deregistered", () => { + beforeEach(() => { + deregisterFeature(di, someFeature); + }); + + it("when injecting a related injectable, throws", () => { + expect(() => { + di.inject(someInjectable); + }).toThrow(); + }); + + it("when injecting an unrelated injectable, does so", () => { + const instance = di.inject(someInjectable2); + + expect(instance).toBe("some-instance-2"); + }); + + describe("given the Feature is registered again", () => { + beforeEach(() => { + registerFeature(di, someFeature); + }); + + it("when injecting a related injectable, does so", () => { + const instance = di.inject(someInjectable); + + expect(instance).toBe("some-instance"); + }); + + it("when injecting an unrelated injectable, does so", () => { + const instance = di.inject(someInjectable2); + + expect(instance).toBe("some-instance-2"); + }); + }); + }); + + it("when a Feature is registered again, throws", () => { + expect(() => { + registerFeature(di, someFeature); + }).toThrow( + 'Tried to register feature "some-feature-1", but it was already registered.' + ); + }); + + it("given a Feature deregistered, when deregistered again, throws", () => { + deregisterFeature(di, someFeature); + + expect(() => { + deregisterFeature(di, someFeature); + }).toThrow( + 'Tried to deregister feature "some-feature-1", but it was not registered.' + ); + }); + }); + + it("given di-container and registered Features with injectables forming a cycle, when an injectable is injected, throws with namespaced error about cycle", () => { + const someInjectable: Injectable = getInjectable({ + id: "some-injectable-1", + instantiate: (di) => di.inject(someInjectable2), + }); + + const someInjectable2: Injectable = getInjectable({ + id: "some-injectable-2", + instantiate: (di) => di.inject(someInjectable), + }); + + const di = createContainer("some-container"); + + const someFeature = getFeature({ + id: "some-feature-1", + + register: (di) => { + di.register(someInjectable); + }, + }); + + const someFeature2 = getFeature({ + id: "some-feature-2", + + register: (di) => { + di.register(someInjectable2); + }, + }); + + registerFeature(di, someFeature, someFeature2); + + expect(() => { + di.inject(someInjectable); + }).toThrow( + // 'Cycle of injectables encountered: "some-container" -> "some-feature-1:some-injectable-1" -> "some-feature-2:some-injectable-2" -> "some-feature-1:some-injectable-1"' + 'Maximum call stack size exceeded' + ); + }); +}); diff --git a/packages/technical-features/feature-core/tsconfig.json b/packages/technical-features/feature-core/tsconfig.json new file mode 100644 index 0000000000..a4f6fa613e --- /dev/null +++ b/packages/technical-features/feature-core/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "@k8slens/typescript/config/base.json" +} diff --git a/packages/technical-features/feature-core/webpack.config.js b/packages/technical-features/feature-core/webpack.config.js new file mode 100644 index 0000000000..3183f30179 --- /dev/null +++ b/packages/technical-features/feature-core/webpack.config.js @@ -0,0 +1 @@ +module.exports = require("@k8slens/webpack").configForNode;