feat: Merge plugin modules (#10895)

Fixes: FRMW-2858

This PR merge the modules exported by the plugins with the modules defined within the user config. As a result, all modules get loaded without changing the internals of the loader.

However, you cannot disable the module of a plugin by re-adding it to the `modules` array. That is something we should handle separately. 

We've added the breaking change label because of the following fix:
We did broke the ability to completely disable modules in the past pr's, in this pr we re add the ability to disable a module and that this modules does not get loaded at all. ([here](https://github.com/medusajs/medusa/pull/10895/commits/6dd164f783d632c927e0a48c50090dcf7387ccdd))

Co-authored-by: Adrien de Peretti <25098370+adrien2p@users.noreply.github.com>
This commit is contained in:
Harminder Virk
2025-01-10 10:02:09 +00:00
committed by GitHub
co-authored by Adrien de Peretti
parent a607b8e800
commit c1930bd656
15 changed files with 244 additions and 87 deletions
+7
View File
@@ -0,0 +1,7 @@
---
"@medusajs/medusa": patch
"@medusajs/types": patch
"@medusajs/utils": patch
---
Feat/merge plugin modules
+2 -1
View File
@@ -106,7 +106,8 @@ export async function loadModules(args: {
let declaration: any = {} let declaration: any = {}
let definition: Partial<ModuleDefinition> | undefined = undefined let definition: Partial<ModuleDefinition> | undefined = undefined
if (mod === false) { // TODO: We are keeping mod === false for backward compatibility for now
if (mod === false || (isObject(mod) && "disable" in mod && mod.disable)) {
continue continue
} }
@@ -944,6 +944,13 @@ type ExternalModuleDeclarationOverride = ExternalModuleDeclaration & {
disable?: boolean disable?: boolean
} }
/**
* Modules accepted by the defineConfig function
*/
export type InputConfigModules = Partial<
InternalModuleDeclarationOverride | ExternalModuleDeclarationOverride
>[]
/** /**
* The configuration accepted by the "defineConfig" helper * The configuration accepted by the "defineConfig" helper
*/ */
@@ -951,9 +958,7 @@ export type InputConfig = Partial<
Omit<ConfigModule, "admin" | "modules"> & { Omit<ConfigModule, "admin" | "modules"> & {
admin: Partial<ConfigModule["admin"]> admin: Partial<ConfigModule["admin"]>
modules: modules:
| Partial< | InputConfigModules
InternalModuleDeclarationOverride | ExternalModuleDeclarationOverride
>[]
/** /**
* @deprecated use the array instead * @deprecated use the array instead
*/ */
@@ -967,5 +972,5 @@ export type PluginDetails = {
id: string id: string
options: Record<string, unknown> options: Record<string, unknown>
version: string version: string
modules?: InputConfig["modules"] modules?: InputConfigModules
} }
@@ -800,7 +800,7 @@ describe("defineConfig", function () {
`) `)
}) })
it("should not include disabled modules", function () { it("should include disabled modules", function () {
expect( expect(
defineConfig({ defineConfig({
projectConfig: { projectConfig: {
@@ -837,6 +837,9 @@ describe("defineConfig", function () {
"cache": { "cache": {
"resolve": "@medusajs/medusa/cache-inmemory", "resolve": "@medusajs/medusa/cache-inmemory",
}, },
"cart": {
"disable": true,
},
"currency": { "currency": {
"resolve": "@medusajs/medusa/currency", "resolve": "@medusajs/medusa/currency",
}, },
@@ -0,0 +1,75 @@
import { MODULE_PACKAGE_NAMES, Modules } from "../../modules-sdk"
import { transformModules } from "../define-config"
describe("transformModules", () => {
test("convert array of modules to an object", () => {
const modules = transformModules([
{
resolve: require.resolve("../__fixtures__/define-config/github"),
options: {
apiKey: "test",
},
},
])
expect(modules).toEqual({
GithubModuleService: {
options: {
apiKey: "test",
},
resolve: require.resolve("../__fixtures__/define-config/github"),
},
})
})
test("transform default module", () => {
const modules = transformModules([
{
resolve: MODULE_PACKAGE_NAMES[Modules.CACHE],
},
])
expect(modules).toEqual({
cache: {
resolve: "@medusajs/medusa/cache-inmemory",
},
})
})
test("should manage loading priority of modules when its disabled at a later stage in the array", () => {
const modules = transformModules([
{
resolve: MODULE_PACKAGE_NAMES[Modules.CACHE],
},
{
resolve: MODULE_PACKAGE_NAMES[Modules.CACHE],
disable: true,
},
])
expect(modules).toEqual({
cache: {
resolve: MODULE_PACKAGE_NAMES[Modules.CACHE],
disable: true,
},
})
})
test("should manage loading priority of modules when its not disabled at a later stage in the array", () => {
const modules = transformModules([
{
resolve: MODULE_PACKAGE_NAMES[Modules.CACHE],
disable: true,
},
{
resolve: MODULE_PACKAGE_NAMES[Modules.CACHE],
},
])
expect(modules).toEqual({
cache: {
resolve: MODULE_PACKAGE_NAMES[Modules.CACHE],
},
})
})
})
+70 -66
View File
@@ -1,6 +1,7 @@
import { import {
ConfigModule, ConfigModule,
InputConfig, InputConfig,
InputConfigModules,
InternalModuleDeclaration, InternalModuleDeclaration,
} from "@medusajs/types" } from "@medusajs/types"
import { import {
@@ -108,14 +109,79 @@ export function defineConfig(config: InputConfig = {}): ConfigModule {
} }
/** /**
* The user API allow to use array of modules configuration. This method manage the loading of the user modules * Transforms an array of modules into an object. The last module will
* along side the default modules and re map them to an object. * take precedence in case of duplicate modules
*/
export function transformModules(
modules: InputConfigModules
): Exclude<ConfigModule["modules"], undefined> {
const remappedModules = modules.reduce((acc, moduleConfig) => {
if (moduleConfig.scope === "external" && !moduleConfig.key) {
throw new Error(
"External modules configuration must have a 'key'. Please provide a key for the module."
)
}
if ("disable" in moduleConfig && "key" in moduleConfig) {
acc[moduleConfig.key!] = moduleConfig
}
// TODO: handle external modules later
let serviceName: string =
"key" in moduleConfig && moduleConfig.key ? moduleConfig.key : ""
delete moduleConfig.key
if (!serviceName && "resolve" in moduleConfig) {
if (
isString(moduleConfig.resolve!) &&
REVERSED_MODULE_PACKAGE_NAMES[moduleConfig.resolve!]
) {
serviceName = REVERSED_MODULE_PACKAGE_NAMES[moduleConfig.resolve!]
acc[serviceName] = moduleConfig
return acc
}
let resolution = isString(moduleConfig.resolve!)
? normalizeImportPathWithSource(moduleConfig.resolve as string)
: moduleConfig.resolve
const moduleExport = isString(resolution)
? require(resolution)
: resolution
const defaultExport = resolveExports(moduleExport).default
const joinerConfig =
typeof defaultExport.service.prototype.__joinerConfig === "function"
? defaultExport.service.prototype.__joinerConfig() ?? {}
: defaultExport.service.prototype.__joinerConfig ?? {}
serviceName = joinerConfig.serviceName
if (!serviceName) {
throw new Error(
`Module ${moduleConfig.resolve} doesn't have a serviceName. Please provide a 'key' for the module or check the service joiner config.`
)
}
}
acc[serviceName] = moduleConfig
return acc
}, {})
return remappedModules as Exclude<ConfigModule["modules"], undefined>
}
/**
* The user API allow to use array of modules configuration. This method manage the loading of the
* user modules along side the default modules and re map them to an object.
* *
* @param configModules * @param configModules
*/ */
function resolveModules( function resolveModules(
configModules: InputConfig["modules"] configModules: InputConfig["modules"]
): ConfigModule["modules"] { ): Exclude<ConfigModule["modules"], undefined> {
/** /**
* The default set of modules to always use. The end user can swap * The default set of modules to always use. The end user can swap
* the modules by providing an alternate implementation via their * the modules by providing an alternate implementation via their
@@ -225,67 +291,5 @@ function resolveModules(
} }
} }
const remappedModules = modules.reduce((acc, moduleConfig) => { return transformModules(modules)
if (moduleConfig.scope === "external" && !moduleConfig.key) {
throw new Error(
"External modules configuration must have a 'key'. Please provide a key for the module."
)
}
if ("disable" in moduleConfig && "key" in moduleConfig) {
acc[moduleConfig.key!] = moduleConfig
}
// TODO: handle external modules later
let serviceName: string =
"key" in moduleConfig && moduleConfig.key ? moduleConfig.key : ""
delete moduleConfig.key
if (!serviceName && "resolve" in moduleConfig) {
if (
isString(moduleConfig.resolve!) &&
REVERSED_MODULE_PACKAGE_NAMES[moduleConfig.resolve!]
) {
serviceName = REVERSED_MODULE_PACKAGE_NAMES[moduleConfig.resolve!]
acc[serviceName] = moduleConfig
return acc
}
let resolution = isString(moduleConfig.resolve!)
? normalizeImportPathWithSource(moduleConfig.resolve as string)
: moduleConfig.resolve
const moduleExport = isString(resolution)
? require(resolution)
: resolution
const defaultExport = resolveExports(moduleExport).default
const joinerConfig =
typeof defaultExport.service.prototype.__joinerConfig === "function"
? defaultExport.service.prototype.__joinerConfig() ?? {}
: defaultExport.service.prototype.__joinerConfig ?? {}
serviceName = joinerConfig.serviceName
if (!serviceName) {
throw new Error(
`Module ${moduleConfig.resolve} doesn't have a serviceName. Please provide a 'key' for the module or check the service joiner config.`
)
}
}
acc[serviceName] = moduleConfig
return acc
}, {})
// Remove any modules set to false
Object.keys(remappedModules).forEach((key) => {
if (remappedModules[key].disable) {
delete remappedModules[key]
}
})
return remappedModules as ConfigModule["modules"]
} }
+1
View File
@@ -80,3 +80,4 @@ export * from "./trim-zeros"
export * from "./upper-case-first" export * from "./upper-case-first"
export * from "./validate-handle" export * from "./validate-handle"
export * from "./wrap-handler" export * from "./wrap-handler"
export * from "./merge-plugin-modules"
@@ -0,0 +1,35 @@
import type {
PluginDetails,
ConfigModule,
InputConfigModules,
} from "@medusajs/types"
import { transformModules } from "./define-config"
/**
* Mutates the configModules object and merges the plugin modules with
* the modules defined inside the user-config file
*/
export function mergePluginModules(
configModule: ConfigModule,
plugins: PluginDetails[]
) {
/**
* Create a flat array of all the modules exposed by the registered
* plugins
*/
const pluginsModules = plugins.reduce((result, plugin) => {
if (plugin.modules) {
result = result.concat(plugin.modules)
}
return result
}, [] as InputConfigModules)
/**
* Merge plugin modules with the modules defined within the
* config file.
*/
configModule.modules = {
...transformModules(pluginsModules),
...configModule.modules,
}
}
@@ -2,6 +2,7 @@ import { join } from "path"
import { import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
MedusaError, MedusaError,
mergePluginModules,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { LinkLoader } from "@medusajs/framework/links" import { LinkLoader } from "@medusajs/framework/links"
import { logger } from "@medusajs/framework/logger" import { logger } from "@medusajs/framework/logger"
@@ -27,6 +28,8 @@ const main = async function ({ directory, modules }) {
) )
const plugins = await getResolvedPlugins(directory, configModule, true) const plugins = await getResolvedPlugins(directory, configModule, true)
mergePluginModules(configModule, plugins)
const linksSourcePaths = plugins.map((plugin) => const linksSourcePaths = plugins.map((plugin) =>
join(plugin.resolve, "links") join(plugin.resolve, "links")
) )
+6 -1
View File
@@ -1,5 +1,8 @@
import { join } from "path" import { join } from "path"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils" import {
ContainerRegistrationKeys,
mergePluginModules,
} from "@medusajs/framework/utils"
import { LinkLoader } from "@medusajs/framework/links" import { LinkLoader } from "@medusajs/framework/links"
import { logger } from "@medusajs/framework/logger" import { logger } from "@medusajs/framework/logger"
import { MedusaAppLoader } from "@medusajs/framework" import { MedusaAppLoader } from "@medusajs/framework"
@@ -38,6 +41,8 @@ export async function migrate({
) )
const plugins = await getResolvedPlugins(directory, configModule, true) const plugins = await getResolvedPlugins(directory, configModule, true)
mergePluginModules(configModule, plugins)
const linksSourcePaths = plugins.map((plugin) => const linksSourcePaths = plugins.map((plugin) =>
join(plugin.resolve, "links") join(plugin.resolve, "links")
) )
@@ -2,6 +2,7 @@ import { join } from "path"
import { import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
MedusaError, MedusaError,
mergePluginModules,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { LinkLoader } from "@medusajs/framework/links" import { LinkLoader } from "@medusajs/framework/links"
import { logger } from "@medusajs/framework/logger" import { logger } from "@medusajs/framework/logger"
@@ -27,6 +28,8 @@ const main = async function ({ directory, modules }) {
) )
const plugins = await getResolvedPlugins(directory, configModule, true) const plugins = await getResolvedPlugins(directory, configModule, true)
mergePluginModules(configModule, plugins)
const linksSourcePaths = plugins.map((plugin) => const linksSourcePaths = plugins.map((plugin) =>
join(plugin.resolve, "links") join(plugin.resolve, "links")
) )
@@ -2,7 +2,10 @@ import boxen from "boxen"
import chalk from "chalk" import chalk from "chalk"
import { join } from "path" import { join } from "path"
import checkbox from "@inquirer/checkbox" import checkbox from "@inquirer/checkbox"
import { ContainerRegistrationKeys } from "@medusajs/framework/utils" import {
ContainerRegistrationKeys,
mergePluginModules,
} from "@medusajs/framework/utils"
import { LinkMigrationsPlannerAction } from "@medusajs/framework/types" import { LinkMigrationsPlannerAction } from "@medusajs/framework/types"
import { LinkLoader } from "@medusajs/framework/links" import { LinkLoader } from "@medusajs/framework/links"
import { logger } from "@medusajs/framework/logger" import { logger } from "@medusajs/framework/logger"
@@ -188,6 +191,8 @@ const main = async function ({ directory, executeSafe, executeAll }) {
const medusaAppLoader = new MedusaAppLoader() const medusaAppLoader = new MedusaAppLoader()
const plugins = await getResolvedPlugins(directory, configModule, true) const plugins = await getResolvedPlugins(directory, configModule, true)
mergePluginModules(configModule, plugins)
const linksSourcePaths = plugins.map((plugin) => const linksSourcePaths = plugins.map((plugin) =>
join(plugin.resolve, "links") join(plugin.resolve, "links")
) )
@@ -33,7 +33,7 @@ describe("getResolvedPlugins | relative paths", () => {
expect(plugins).toEqual([ expect(plugins).toEqual([
{ {
resolve: path.join(fs.basePath, "./plugins/dummy/build"), resolve: path.join(fs.basePath, "./plugins/dummy/.medusa/server/src"),
name: "my-dummy-plugin", name: "my-dummy-plugin",
id: "my-dummy-plugin", id: "my-dummy-plugin",
options: { apiKey: "asecret" }, options: { apiKey: "asecret" },
@@ -48,7 +48,10 @@ describe("getResolvedPlugins | relative paths", () => {
name: "my-dummy-plugin", name: "my-dummy-plugin",
version: "1.0.0", version: "1.0.0",
}) })
await fs.create("plugins/dummy/build/modules/blog/index.js", ``) await fs.create(
"plugins/dummy/.medusa/server/src/modules/blog/index.js",
``
)
const plugins = await getResolvedPlugins( const plugins = await getResolvedPlugins(
fs.basePath, fs.basePath,
@@ -67,7 +70,7 @@ describe("getResolvedPlugins | relative paths", () => {
expect(plugins).toEqual([ expect(plugins).toEqual([
{ {
resolve: path.join(fs.basePath, "./plugins/dummy/build"), resolve: path.join(fs.basePath, "./plugins/dummy/.medusa/server/src"),
name: "my-dummy-plugin", name: "my-dummy-plugin",
id: "my-dummy-plugin", id: "my-dummy-plugin",
options: { apiKey: "asecret" }, options: { apiKey: "asecret" },
@@ -77,7 +80,7 @@ describe("getResolvedPlugins | relative paths", () => {
options: { options: {
apiKey: "asecret", apiKey: "asecret",
}, },
resolve: "./plugins/dummy/build/modules/blog", resolve: "./plugins/dummy/.medusa/server/src/modules/blog",
}, },
], ],
}, },
@@ -132,7 +135,10 @@ describe("getResolvedPlugins | package reference", () => {
expect(plugins).toEqual([ expect(plugins).toEqual([
{ {
resolve: path.join(fs.basePath, "node_modules/@plugins/dummy/build"), resolve: path.join(
fs.basePath,
"node_modules/@plugins/dummy/.medusa/server/src"
),
name: "my-dummy-plugin", name: "my-dummy-plugin",
id: "my-dummy-plugin", id: "my-dummy-plugin",
options: { apiKey: "asecret" }, options: { apiKey: "asecret" },
@@ -149,7 +155,7 @@ describe("getResolvedPlugins | package reference", () => {
version: "1.0.0", version: "1.0.0",
}) })
await fs.create( await fs.create(
"node_modules/@plugins/dummy/build/modules/blog/index.js", "node_modules/@plugins/dummy/.medusa/server/src/modules/blog/index.js",
`` ``
) )
@@ -170,7 +176,10 @@ describe("getResolvedPlugins | package reference", () => {
expect(plugins).toEqual([ expect(plugins).toEqual([
{ {
resolve: path.join(fs.basePath, "node_modules/@plugins/dummy/build"), resolve: path.join(
fs.basePath,
"node_modules/@plugins/dummy/.medusa/server/src"
),
name: "my-dummy-plugin", name: "my-dummy-plugin",
id: "my-dummy-plugin", id: "my-dummy-plugin",
options: { apiKey: "asecret" }, options: { apiKey: "asecret" },
@@ -180,7 +189,7 @@ describe("getResolvedPlugins | package reference", () => {
options: { options: {
apiKey: "asecret", apiKey: "asecret",
}, },
resolve: "@plugins/dummy/build/modules/blog", resolve: "@plugins/dummy/.medusa/server/src/modules/blog",
}, },
], ],
}, },
@@ -4,6 +4,7 @@ import { isString, readDir } from "@medusajs/framework/utils"
import { ConfigModule, PluginDetails } from "@medusajs/framework/types" import { ConfigModule, PluginDetails } from "@medusajs/framework/types"
const MEDUSA_APP_SOURCE_PATH = "src" const MEDUSA_APP_SOURCE_PATH = "src"
const MEDUSA_PLUGIN_SOURCE_PATH = ".medusa/server/src"
export const MEDUSA_PROJECT_NAME = "project-plugin" export const MEDUSA_PROJECT_NAME = "project-plugin"
function createPluginId(name: string): string { function createPluginId(name: string): string {
@@ -57,11 +58,8 @@ async function resolvePlugin(
const resolvedPath = path.dirname(pkgJSON.path) const resolvedPath = path.dirname(pkgJSON.path)
const name = pkgJSON.contents.name || pluginPath const name = pkgJSON.contents.name || pluginPath
const srcDir = pkgJSON.contents.main
? path.dirname(pkgJSON.contents.main)
: "build"
const resolve = path.join(resolvedPath, srcDir) const resolve = path.join(resolvedPath, MEDUSA_PLUGIN_SOURCE_PATH)
const modules = await readDir(path.join(resolve, "modules"), { const modules = await readDir(path.join(resolve, "modules"), {
ignoreMissing: true, ignoreMissing: true,
}) })
@@ -75,7 +73,7 @@ async function resolvePlugin(
version: pkgJSON.contents.version || "0.0.0", version: pkgJSON.contents.version || "0.0.0",
modules: modules.map((mod) => { modules: modules.map((mod) => {
return { return {
resolve: `${pluginPath}/${srcDir}/modules/${mod.name}`, resolve: `${pluginPath}/${MEDUSA_PLUGIN_SOURCE_PATH}/modules/${mod.name}`,
options: pluginOptions, options: pluginOptions,
} }
}), }),
+3
View File
@@ -7,6 +7,7 @@ import {
import { import {
ContainerRegistrationKeys, ContainerRegistrationKeys,
GraphQLSchema, GraphQLSchema,
mergePluginModules,
promiseAll, promiseAll,
} from "@medusajs/framework/utils" } from "@medusajs/framework/utils"
import { asValue } from "awilix" import { asValue } from "awilix"
@@ -147,6 +148,8 @@ export default async ({
) )
const plugins = await getResolvedPlugins(rootDirectory, configModule, true) const plugins = await getResolvedPlugins(rootDirectory, configModule, true)
mergePluginModules(configModule, plugins)
const linksSourcePaths = plugins.map((plugin) => const linksSourcePaths = plugins.map((plugin) =>
join(plugin.resolve, "links") join(plugin.resolve, "links")
) )