Files
medusa-store/packages/core/modules-sdk/src/medusa-module.ts
Carlos R. L. Rodrigues e413cfefc2 feat(utils): define file config (#13283)
** What
 - Allow auto-loaded Medusa files to export a config object.
 - Currently supports isDisabled to control loading.
 - new instance `FeatureFlag` exported by `@medusajs/framework/utils`
 - `feature-flags` is now a supported folder for medusa projects, modules, providers and plugins. They will be loaded and added to `FeatureFlag`

** Why
 - Enables conditional loading of routes, migrations, jobs, subscribers, workflows, and other files based on feature flags.

```ts
// /src/feature-flags

import { FlagSettings } from "@medusajs/framework/feature-flags"

const CustomFeatureFlag: FlagSettings = {
  key: "custom_feature",
  default_val: false,
  env_key: "FF_MY_CUSTOM_FEATURE",
  description: "Enable xyz",
}

export default CustomFeatureFlag
```

```ts
// /src/modules/my-custom-module/migration/Migration20250822135845.ts

import { FeatureFlag } from "@medusajs/framework/utils"

export class Migration20250822135845 extends Migration {
  override async up(){ }
  override async down(){ }
}

defineFileConfig({
  isDisabled: () => !FeatureFlag.isFeatureEnabled("custom_feature")
})
```
2025-08-26 12:22:30 +00:00

890 lines
23 KiB
TypeScript

import {
ExternalModuleDeclaration,
IModuleService,
InternalModuleDeclaration,
LinkModuleDefinition,
LoadedModule,
MedusaContainer,
ModuleBootstrapDeclaration,
ModuleDefinition,
ModuleExports,
ModuleJoinerConfig,
ModuleResolution,
} from "@medusajs/types"
import {
ContainerRegistrationKeys,
createMedusaContainer,
promiseAll,
simpleHash,
stringifyCircular,
} from "@medusajs/utils"
import { asValue } from "awilix"
import { EOL } from "os"
import {
moduleLoader,
registerMedusaLinkModule,
registerMedusaModule,
} from "./loaders"
import { loadModuleMigrations } from "./loaders/utils"
import { MODULE_SCOPE } from "./types"
const logger: any = {
log: (a) => console.log(a),
info: (a) => console.log(a),
warn: (a) => console.warn(a),
error: (a) => console.error(a),
}
declare global {
interface MedusaModule {
getLoadedModules(
aliases?: Map<string, string>
): { [key: string]: LoadedModule }[]
getModuleInstance(moduleKey: string, alias?: string): LoadedModule
}
}
type ModuleAlias = {
key: string
hash: string
isLink: boolean
alias?: string
main?: boolean
}
export type MigrationOptions = {
moduleKey: string
modulePath: string
container?: MedusaContainer
options?: Record<string, any>
moduleExports?: ModuleExports
cwd?: string
}
export type ModuleBootstrapOptions = {
moduleKey: string
defaultPath: string
declaration?: ModuleBootstrapDeclaration
moduleExports?: ModuleExports
sharedContainer?: MedusaContainer
moduleDefinition?: ModuleDefinition
injectedDependencies?: Record<string, any>
/**
* In this mode, all instances are partially loaded, meaning that the module will not be fully loaded and the services will not be available.
* Don't forget to clear the instances (MedusaModule.clearInstances()) after the migration are done.
*/
migrationOnly?: boolean
/**
* Forces the modules bootstrapper to only run the modules loaders and return prematurely. This
* is meant for modules that have data loader. In a test env, in order to clear all data
* and load them back, we need to run those loader again
*/
loaderOnly?: boolean
workerMode?: "shared" | "worker" | "server"
cwd?: string
}
export type LinkModuleBootstrapOptions = {
definition: LinkModuleDefinition
declaration?: InternalModuleDeclaration
moduleExports?: ModuleExports
injectedDependencies?: Record<string, any>
cwd?: string
}
export type RegisterModuleJoinerConfig =
| ModuleJoinerConfig
| ((modules: ModuleJoinerConfig[]) => ModuleJoinerConfig)
class MedusaModule {
private static instances_: Map<string, { [key: string]: IModuleService }> =
new Map()
private static modules_: Map<string, ModuleAlias[]> = new Map()
private static customLinks_: RegisterModuleJoinerConfig[] = []
private static loading_: Map<string, Promise<any>> = new Map()
private static joinerConfig_: Map<string, ModuleJoinerConfig> = new Map()
private static moduleResolutions_: Map<string, ModuleResolution> = new Map()
public static getLoadedModules(
aliases?: Map<string, string>
): { [key: string]: LoadedModule }[] {
return [...MedusaModule.modules_.entries()].map(([key]) => {
if (aliases?.has(key)) {
return MedusaModule.getModuleInstance(key, aliases.get(key))
}
return MedusaModule.getModuleInstance(key)
})
}
public static onApplicationStart(onApplicationStartCb?: () => void): void {
for (const instances of MedusaModule.instances_.values()) {
for (const instance of Object.values(instances) as IModuleService[]) {
if (instance?.__hooks) {
instance.__hooks?.onApplicationStart
?.bind(instance)()
.then(() => {
onApplicationStartCb?.()
})
.catch(() => {
// The module should handle this and log it
return void 0
})
}
}
}
}
public static async onApplicationShutdown(): Promise<void> {
await promiseAll(
[...MedusaModule.instances_.values()]
.map((instances) => {
return Object.values(instances).map((instance: IModuleService) => {
return instance.__hooks?.onApplicationShutdown
?.bind(instance)()
.catch(() => {
// The module should handle this and log it
return void 0
})
})
})
.flat()
)
}
public static async onApplicationPrepareShutdown(): Promise<void> {
await promiseAll(
[...MedusaModule.instances_.values()]
.map((instances) => {
return Object.values(instances).map((instance: IModuleService) => {
return instance.__hooks?.onApplicationPrepareShutdown
?.bind(instance)()
.catch(() => {
// The module should handle this and log it
return void 0
})
})
})
.flat()
)
}
public static clearInstances(): void {
MedusaModule.instances_.clear()
MedusaModule.modules_.clear()
MedusaModule.joinerConfig_.clear()
MedusaModule.moduleResolutions_.clear()
MedusaModule.customLinks_.length = 0
}
public static isInstalled(moduleKey: string, alias?: string): boolean {
if (alias) {
return (
MedusaModule.modules_.has(moduleKey) &&
MedusaModule.modules_.get(moduleKey)!.some((m) => m.alias === alias)
)
}
return MedusaModule.modules_.has(moduleKey)
}
public static getJoinerConfig(moduleKey: string): ModuleJoinerConfig {
return MedusaModule.joinerConfig_.get(moduleKey)!
}
public static getAllJoinerConfigs(): ModuleJoinerConfig[] {
return [...MedusaModule.joinerConfig_.values()]
}
public static getModuleResolutions(moduleKey: string): ModuleResolution {
return MedusaModule.moduleResolutions_.get(moduleKey)!
}
public static getAllModuleResolutions(): ModuleResolution[] {
return [...MedusaModule.moduleResolutions_.values()]
}
public static setModuleResolution(
moduleKey: string,
resolution: ModuleResolution
): ModuleResolution {
MedusaModule.moduleResolutions_.set(moduleKey, resolution)
return resolution
}
public static setJoinerConfig(
moduleKey: string,
config: ModuleJoinerConfig
): ModuleJoinerConfig {
MedusaModule.joinerConfig_.set(moduleKey, config)
return config
}
public static setCustomLink(config: RegisterModuleJoinerConfig): void {
MedusaModule.customLinks_.push(config)
}
public static getCustomLinks(): RegisterModuleJoinerConfig[] {
return MedusaModule.customLinks_
}
public static getModuleInstance(
moduleKey: string,
alias?: string
): any | undefined {
if (!MedusaModule.modules_.has(moduleKey)) {
return
}
let mod
const modules = MedusaModule.modules_.get(moduleKey)!
if (alias) {
mod = modules.find((m) => m.alias === alias)
return MedusaModule.instances_.get(mod?.hash)
}
mod = modules.find((m) => m.main) ?? modules[0]
return MedusaModule.instances_.get(mod?.hash)
}
private static registerModule(
moduleKey: string,
loadedModule: ModuleAlias
): void {
if (!MedusaModule.modules_.has(moduleKey)) {
MedusaModule.modules_.set(moduleKey, [])
}
const modules = MedusaModule.modules_.get(moduleKey)!
if (modules.some((m) => m.alias === loadedModule.alias)) {
throw new Error(
`Module ${moduleKey} already registed as '${loadedModule.alias}'. Please choose a different alias.`
)
}
if (loadedModule.main) {
if (modules.some((m) => m.main)) {
throw new Error(`Module ${moduleKey} already have a 'main' registered.`)
}
}
modules.push(loadedModule)
MedusaModule.modules_.set(moduleKey, modules!)
}
/**
* Load all modules and resolve them once they are loaded
* @param modulesOptions
* @param migrationOnly
* @param loaderOnly
* @param workerMode
*/
public static async bootstrapAll(
modulesOptions: Omit<
ModuleBootstrapOptions,
"migrationOnly" | "loaderOnly" | "workerMode"
>[],
{
migrationOnly,
loaderOnly,
workerMode,
cwd,
}: {
migrationOnly?: boolean
loaderOnly?: boolean
workerMode?: ModuleBootstrapOptions["workerMode"]
cwd?: string
}
): Promise<
{
[key: string]: any
}[]
> {
return await MedusaModule.bootstrap_(modulesOptions, {
migrationOnly,
loaderOnly,
workerMode,
cwd,
})
}
/**
* Load a single module and resolve it once it is loaded
* @param moduleKey
* @param defaultPath
* @param declaration
* @param moduleExports
* @param sharedContainer
* @param moduleDefinition
* @param injectedDependencies
* @param migrationOnly
* @param loaderOnly
* @param workerMode
*/
public static async bootstrap<T>({
moduleKey,
defaultPath,
declaration,
moduleExports,
sharedContainer,
moduleDefinition,
injectedDependencies,
migrationOnly,
loaderOnly,
workerMode,
cwd,
}: ModuleBootstrapOptions): Promise<{
[key: string]: T
}> {
const [service] = await MedusaModule.bootstrap_(
[
{
moduleKey,
defaultPath,
declaration,
moduleExports,
sharedContainer,
moduleDefinition,
injectedDependencies,
},
],
{
migrationOnly,
loaderOnly,
workerMode,
cwd,
}
)
return service as {
[key: string]: T
}
}
/**
* Load all modules and then resolve them once they are loaded
*
* @param modulesOptions
* @param migrationOnly
* @param loaderOnly
* @param workerMode
* @protected
*/
protected static async bootstrap_<T>(
modulesOptions: Omit<
ModuleBootstrapOptions,
"migrationOnly" | "loaderOnly" | "workerMode" | "cwd"
>[],
{
migrationOnly,
loaderOnly,
workerMode,
cwd = process.cwd(),
}: {
migrationOnly?: boolean
loaderOnly?: boolean
workerMode?: "shared" | "worker" | "server"
cwd?: string
}
): Promise<
{
[key: string]: T
}[]
> {
let loadedModules: {
hashKey: string
modDeclaration: InternalModuleDeclaration | ExternalModuleDeclaration
moduleResolutions: Record<string, ModuleResolution>
container: MedusaContainer
finishLoading: (arg: { [Key: string]: any }) => void
}[] = []
const services: { [Key: string]: any }[] = []
await promiseAll(
modulesOptions.map(async (moduleOptions) => {
const {
moduleKey,
defaultPath,
declaration,
moduleExports,
sharedContainer,
moduleDefinition,
injectedDependencies,
} = moduleOptions
const hashKey = simpleHash(
stringifyCircular({ moduleKey, defaultPath, declaration })
)
let finishLoading: any
let errorLoading: any
const loadingPromise = new Promise((resolve, reject) => {
finishLoading = resolve
errorLoading = reject
})
if (!loaderOnly && MedusaModule.instances_.has(hashKey)) {
services.push(MedusaModule.instances_.get(hashKey)!)
return
}
if (!loaderOnly && MedusaModule.loading_.has(hashKey)) {
services.push(await MedusaModule.loading_.get(hashKey))
return
}
if (!loaderOnly) {
MedusaModule.loading_.set(hashKey, loadingPromise)
}
let modDeclaration =
declaration ??
({} as InternalModuleDeclaration | ExternalModuleDeclaration)
if (declaration?.scope !== MODULE_SCOPE.EXTERNAL) {
modDeclaration = {
scope: declaration?.scope || MODULE_SCOPE.INTERNAL,
resolve: defaultPath,
options: declaration?.options ?? declaration,
dependencies:
(declaration as InternalModuleDeclaration)?.dependencies ?? [],
alias: declaration?.alias,
main: declaration?.main,
worker_mode: workerMode,
} as InternalModuleDeclaration
}
const container = sharedContainer ?? createMedusaContainer()
if (injectedDependencies) {
for (const service in injectedDependencies) {
container.register(service, asValue(injectedDependencies[service]))
if (!container.hasRegistration(service)) {
container.register(
service,
asValue(injectedDependencies[service])
)
}
}
}
const moduleResolutions = registerMedusaModule({
moduleKey,
moduleDeclaration: modDeclaration!,
moduleExports,
definition: moduleDefinition,
cwd,
})
const logger_ =
container.resolve(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) ?? logger
try {
await moduleLoader({
container,
moduleResolutions,
logger: logger_,
migrationOnly,
loaderOnly,
})
} catch (err) {
errorLoading(err)
throw err
}
loadedModules.push({
hashKey,
modDeclaration,
moduleResolutions,
container,
finishLoading,
})
})
)
if (loaderOnly) {
loadedModules.forEach(({ finishLoading }) => finishLoading({}))
return [{}]
}
for (const {
hashKey,
modDeclaration,
moduleResolutions,
container,
finishLoading,
} of loadedModules) {
const service = await MedusaModule.resolveLoadedModule({
hashKey,
modDeclaration,
moduleResolutions,
container,
})
MedusaModule.instances_.set(hashKey, service)
finishLoading(service)
MedusaModule.loading_.delete(hashKey)
services.push(service)
}
return services
}
/**
* Resolve all the modules once they all have been loaded through the bootstrap
* and store their references in the instances_ map and return them
*
* @param hashKey
* @param modDeclaration
* @param moduleResolutions
* @param container
* @private
*/
private static async resolveLoadedModule({
hashKey,
modDeclaration,
moduleResolutions,
container,
}: {
hashKey: string
modDeclaration: InternalModuleDeclaration | ExternalModuleDeclaration
moduleResolutions: Record<string, ModuleResolution>
container: MedusaContainer
}): Promise<{
[key: string]: any
}> {
const logger_ =
container.resolve(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) ?? logger
const services: { [key: string]: any } = {}
for (const resolution of Object.values(
moduleResolutions
) as ModuleResolution[]) {
const keyName = resolution.definition.key
services[keyName] = container.resolve(keyName)
services[keyName].__definition = resolution.definition
services[keyName].__definition.resolvePath =
"resolve" in modDeclaration &&
typeof modDeclaration.resolve === "string"
? modDeclaration.resolve
: undefined
if (resolution.definition.isQueryable) {
let joinerConfig!: ModuleJoinerConfig
try {
// TODO: rework that to store on a separate property
joinerConfig = await services[keyName].__joinerConfig?.()
} catch {
// noop
}
if (!joinerConfig) {
throw new Error(
`Your module is missing a joiner config: ${keyName}. If this module is not queryable, please set { definition: { isQueryable: false } } in your module configuration.`
)
}
if (!joinerConfig.primaryKeys) {
logger_.warn(
`Primary keys are not defined by the module ${keyName}. Setting default primary key to 'id'${EOL}`
)
joinerConfig.primaryKeys = ["id"]
}
services[keyName].__joinerConfig = joinerConfig
MedusaModule.setJoinerConfig(keyName, joinerConfig)
}
MedusaModule.setModuleResolution(keyName, resolution)
MedusaModule.registerModule(keyName, {
key: keyName,
hash: hashKey,
alias: modDeclaration.alias ?? hashKey,
main: !!modDeclaration.main,
isLink: false,
})
}
return services
}
public static async bootstrapLink({
definition,
declaration,
moduleExports,
injectedDependencies,
cwd,
}: LinkModuleBootstrapOptions): Promise<{
[key: string]: unknown
}> {
const moduleKey = definition.key
const hashKey = simpleHash(stringifyCircular({ moduleKey, declaration }))
if (MedusaModule.instances_.has(hashKey)) {
return { [moduleKey]: MedusaModule.instances_.get(hashKey) }
}
if (MedusaModule.loading_.has(hashKey)) {
return await MedusaModule.loading_.get(hashKey)
}
let finishLoading: any
let errorLoading: any
MedusaModule.loading_.set(
hashKey,
new Promise((resolve, reject) => {
finishLoading = resolve
errorLoading = reject
})
)
let modDeclaration =
declaration ?? ({} as Partial<InternalModuleDeclaration>)
const moduleDefinition: ModuleDefinition = {
key: definition.key,
dependencies: definition.dependencies,
defaultPackage: "",
label: definition.label,
isRequired: false,
isQueryable: true,
defaultModuleDeclaration: definition.defaultModuleDeclaration,
}
modDeclaration = {
resolve: "",
options: declaration,
alias: declaration?.alias,
main: declaration?.main,
}
const container = createMedusaContainer()
if (injectedDependencies) {
for (const service in injectedDependencies) {
container.register(service, asValue(injectedDependencies[service]))
}
}
const moduleResolutions = registerMedusaLinkModule(
moduleDefinition,
modDeclaration as InternalModuleDeclaration,
moduleExports,
cwd
)
const logger_ =
container.resolve(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) ?? logger
try {
await moduleLoader({
container,
moduleResolutions,
logger: logger_,
})
} catch (err) {
errorLoading(err)
throw err
}
const services = {}
for (const resolution of Object.values(
moduleResolutions
) as ModuleResolution[]) {
const keyName = resolution.definition.key
services[keyName] = container.resolve(keyName)
services[keyName].__definition = resolution.definition
if (resolution.definition.isQueryable) {
let joinerConfig!: ModuleJoinerConfig
try {
joinerConfig = await services[keyName].__joinerConfig?.()
} catch {
// noop
}
if (!joinerConfig) {
throw new Error(
`Your module is missing a joiner config: ${keyName}. If this module is not queryable, please set { definition: { isQueryable: false } } in your module configuration.`
)
}
services[keyName].__joinerConfig = joinerConfig
MedusaModule.setJoinerConfig(keyName, joinerConfig)
if (!joinerConfig.isLink) {
throw new Error(
"MedusaModule.bootstrapLink must be used only for Link Modules"
)
}
}
MedusaModule.setModuleResolution(keyName, resolution)
MedusaModule.registerModule(keyName, {
key: keyName,
hash: hashKey,
alias: modDeclaration.alias ?? hashKey,
main: !!modDeclaration.main,
isLink: true,
})
}
MedusaModule.instances_.set(hashKey, services)
finishLoading(services)
MedusaModule.loading_.delete(hashKey)
return services
}
public static async migrateGenerate({
options,
container,
moduleExports,
moduleKey,
modulePath,
cwd,
}: MigrationOptions): Promise<void> {
const moduleResolutions = registerMedusaModule({
moduleKey,
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resolve: modulePath,
options,
},
cwd,
})
const logger_ =
container?.resolve(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) ?? logger
container ??= createMedusaContainer()
for (const mod in moduleResolutions) {
const { generateMigration } = await loadModuleMigrations(
container,
moduleResolutions[mod],
moduleExports
)
if (typeof generateMigration === "function") {
await generateMigration({
options,
container: container!,
logger: logger_,
})
}
}
}
public static async migrateUp({
options,
container,
moduleExports,
moduleKey,
modulePath,
cwd,
}: MigrationOptions): Promise<void> {
const moduleResolutions = registerMedusaModule({
moduleKey,
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resolve: modulePath,
options,
},
cwd,
})
const logger_ =
container?.resolve(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) ?? logger
container ??= createMedusaContainer()
for (const mod in moduleResolutions) {
const { runMigrations } = await loadModuleMigrations(
container,
moduleResolutions[mod],
moduleExports
)
if (typeof runMigrations === "function") {
await runMigrations({
options,
container: container!,
logger: logger_,
})
}
}
}
public static async migrateDown({
options,
container,
moduleExports,
moduleKey,
modulePath,
cwd,
}: MigrationOptions): Promise<void> {
const moduleResolutions = registerMedusaModule({
moduleKey,
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resolve: modulePath,
options,
},
cwd,
})
const logger_ =
container?.resolve(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) ?? logger
container ??= createMedusaContainer()
for (const mod in moduleResolutions) {
const { revertMigration } = await loadModuleMigrations(
container,
moduleResolutions[mod],
moduleExports
)
if (typeof revertMigration === "function") {
await revertMigration({
options,
container: container!,
logger: logger_,
})
}
}
}
}
global.MedusaModule ??= MedusaModule
const GlobalMedusaModule = global.MedusaModule as typeof MedusaModule
export { GlobalMedusaModule as MedusaModule }