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")
})
```
This commit is contained in:
Carlos R. L. Rodrigues
2025-08-26 09:22:30 -03:00
committed by GitHub
parent 4cda412243
commit e413cfefc2
183 changed files with 1089 additions and 605 deletions

View File

@@ -1,8 +1,8 @@
export * from "./definitions"
export * from "./link"
export * from "./loaders"
export * from "./medusa-app"
export * from "./medusa-module"
export * from "./link"
export * from "./remote-query"
export * from "./types"
export * from "./utils"

View File

@@ -1,4 +1,4 @@
import { InternalModuleDeclaration, ModuleDefinition } from "@medusajs/types"
import { ModuleDefinition } from "@medusajs/types"
import { ModulesDefinition } from "../../definitions"
import { MODULE_SCOPE } from "../../types"
import { registerMedusaModule } from "../register-modules"
@@ -35,7 +35,9 @@ describe("module definitions loader", () => {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key)
const res = registerMedusaModule({
moduleKey: defaultDefinition.key,
})
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
@@ -50,10 +52,13 @@ describe("module definitions loader", () => {
})
it("Resolves a custom module without pre-defined definition", () => {
const res = registerMedusaModule("customModulesABC", {
resolve: testServiceResolved,
options: {
test: 123,
const res = registerMedusaModule({
moduleKey: "customModulesABC",
moduleDeclaration: {
resolve: testServiceResolved,
options: {
test: 123,
},
},
})
@@ -80,7 +85,10 @@ describe("module definitions loader", () => {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key, false)
const res = registerMedusaModule({
moduleKey: defaultDefinition.key,
moduleDeclaration: false,
})
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
@@ -98,7 +106,10 @@ describe("module definitions loader", () => {
})
try {
registerMedusaModule(defaultDefinition.key, false)
registerMedusaModule({
moduleKey: defaultDefinition.key,
moduleDeclaration: false,
})
} catch (err) {
expect(err.message).toEqual(
`Module: ${defaultDefinition.label} is required`
@@ -118,7 +129,9 @@ describe("module definitions loader", () => {
[defaultDefinition.key]: definition,
})
const res = registerMedusaModule(defaultDefinition.key)
const res = registerMedusaModule({
moduleKey: defaultDefinition.key,
})
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
@@ -138,10 +151,10 @@ describe("module definitions loader", () => {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(
defaultDefinition.key,
defaultDefinition.defaultPackage
)
const res = registerMedusaModule({
moduleKey: defaultDefinition.key,
moduleDeclaration: defaultDefinition.defaultPackage,
})
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
@@ -162,10 +175,13 @@ describe("module definitions loader", () => {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key, {
scope: MODULE_SCOPE.INTERNAL,
resolve: defaultDefinition.defaultPackage,
} as InternalModuleDeclaration)
const res = registerMedusaModule({
moduleKey: defaultDefinition.key,
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resolve: defaultDefinition.defaultPackage as string,
},
})
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
@@ -186,9 +202,12 @@ describe("module definitions loader", () => {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key, {
options: { test: 123 },
} as any)
const res = registerMedusaModule({
moduleKey: defaultDefinition.key,
moduleDeclaration: {
options: { test: 123 },
},
})
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({
@@ -209,11 +228,14 @@ describe("module definitions loader", () => {
[defaultDefinition.key]: defaultDefinition,
})
const res = registerMedusaModule(defaultDefinition.key, {
resolve: defaultDefinition.defaultPackage,
options: { test: 123 },
scope: "internal",
} as any)
const res = registerMedusaModule({
moduleKey: defaultDefinition.key,
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resolve: defaultDefinition.defaultPackage as string,
options: { test: 123 },
},
})
expect(res[defaultDefinition.key]).toEqual(
expect.objectContaining({

View File

@@ -1,6 +1,7 @@
import { MedusaContainer, ModuleProvider } from "@medusajs/types"
import {
dynamicImport,
isFileSkipped,
isString,
lowerCaseFirst,
normalizeImportPathWithSource,
@@ -53,6 +54,10 @@ export async function loadModuleProvider(
)
}
if (isFileSkipped(loadedProvider)) {
return
}
loadedProvider = (loadedProvider as any).default ?? loadedProvider
if (!loadedProvider?.services?.length) {

View File

@@ -14,15 +14,22 @@ import {
import { ModulesDefinition } from "../definitions"
import { MODULE_SCOPE } from "../types"
export const registerMedusaModule = (
moduleKey: string,
export const registerMedusaModule = ({
moduleKey,
moduleDeclaration,
moduleExports,
definition,
cwd,
}: {
moduleKey: string
moduleDeclaration?:
| Partial<InternalModuleDeclaration | ExternalModuleDeclaration>
| string
| false,
moduleExports?: ModuleExports,
| false
moduleExports?: ModuleExports
definition?: ModuleDefinition
): Record<string, ModuleResolution> => {
cwd?: string
}): Record<string, ModuleResolution> => {
const moduleResolutions = {} as Record<string, ModuleResolution>
const modDefinition = definition ?? ModulesDefinition[moduleKey]
@@ -46,7 +53,8 @@ export const registerMedusaModule = (
if (modDefinition === undefined) {
moduleResolutions[moduleKey] = getCustomModuleResolution(
moduleKey,
moduleDeclaration as InternalModuleDeclaration
moduleDeclaration as InternalModuleDeclaration,
cwd
)
return moduleResolutions
}
@@ -54,7 +62,8 @@ export const registerMedusaModule = (
moduleResolutions[moduleKey] = getInternalModuleResolution(
modDefinition,
moduleDeclaration as InternalModuleDeclaration,
moduleExports
moduleExports,
cwd
)
return moduleResolutions
@@ -62,13 +71,15 @@ export const registerMedusaModule = (
function getCustomModuleResolution(
key: string,
moduleConfig: InternalModuleDeclaration | string
moduleConfig: InternalModuleDeclaration | string,
cwd: string = process.cwd()
): ModuleResolution {
const originalPath = normalizeImportPathWithSource(
(isString(moduleConfig) ? moduleConfig : moduleConfig.resolve) as string
(isString(moduleConfig) ? moduleConfig : moduleConfig.resolve) as string,
cwd
)
const resolutionPath = require.resolve(originalPath, {
paths: [process.cwd()],
paths: [cwd],
})
const conf = isObject(moduleConfig)
@@ -100,14 +111,16 @@ function getCustomModuleResolution(
export const registerMedusaLinkModule = (
definition: ModuleDefinition,
moduleDeclaration: Partial<InternalModuleDeclaration>,
moduleExports?: ModuleExports
moduleExports?: ModuleExports,
cwd: string = process.cwd()
): Record<string, ModuleResolution> => {
const moduleResolutions = {} as Record<string, ModuleResolution>
moduleResolutions[definition.key] = getInternalModuleResolution(
definition,
moduleDeclaration as InternalModuleDeclaration,
moduleExports
moduleExports,
cwd
)
return moduleResolutions
@@ -116,7 +129,8 @@ export const registerMedusaLinkModule = (
function getInternalModuleResolution(
definition: ModuleDefinition,
moduleConfig: InternalModuleDeclaration | string | false,
moduleExports?: ModuleExports
moduleExports?: ModuleExports,
cwd: string = process.cwd()
): ModuleResolution {
if (typeof moduleConfig === "boolean") {
if (!moduleConfig && definition.isRequired) {
@@ -140,10 +154,11 @@ function getInternalModuleResolution(
const isStr = isString(moduleConfig)
if (isStr || (isObj && moduleConfig.resolve)) {
const originalPath = normalizeImportPathWithSource(
(isString(moduleConfig) ? moduleConfig : moduleConfig.resolve) as string
(isString(moduleConfig) ? moduleConfig : moduleConfig.resolve) as string,
cwd
)
resolutionPath = require.resolve(originalPath, {
paths: [process.cwd()],
paths: [cwd],
})
}

View File

@@ -16,6 +16,7 @@ import { ModuleProviderService as ModuleServiceWithProviderProvider1 } from "../
import { ModuleProvider2Service as ModuleServiceWithProviderProvider2 } from "../__fixtures__/module-with-providers/provider-2"
import { loadInternalModule, loadResources } from "../load-internal"
const container = createMedusaContainer()
describe("load internal", () => {
describe("loadResources", () => {
describe("when loading the module resources from a path", () => {
@@ -43,6 +44,7 @@ describe("load internal", () => {
).toBeUndefined()
const resources = await loadResources({
container,
moduleResolution,
discoveryPath: moduleResolution.resolutionPath as string,
})
@@ -125,6 +127,7 @@ describe("load internal", () => {
).toBeUndefined()
const resources = await loadResources({
container,
moduleResolution,
discoveryPath: moduleResolution.resolutionPath as string,
})
@@ -207,6 +210,7 @@ describe("load internal", () => {
).toBeUndefined()
const resources = await loadResources({
container,
moduleResolution,
discoveryPath: moduleResolution.resolutionPath as string,
})
@@ -288,6 +292,7 @@ describe("load internal", () => {
).toBeDefined()
const resources = await loadResources({
container,
moduleResolution,
discoveryPath: moduleResolution.resolutionPath as string,
})

View File

@@ -1,4 +1,5 @@
import {
ConfigModule,
Constructor,
IModuleService,
InternalModuleDeclaration,
@@ -16,14 +17,18 @@ import {
ContainerRegistrationKeys,
createMedusaContainer,
defineJoinerConfig,
discoverFeatureFlagsFromDir,
DmlEntity,
dynamicImport,
FeatureFlag,
getProviderRegistrationKey,
isFileSkipped,
isString,
MedusaModuleProviderType,
MedusaModuleType,
Modules,
ModulesSdkUtils,
registerFeatureFlag,
stringifyCircular,
toMikroOrmEntities,
} from "@medusajs/utils"
@@ -193,6 +198,7 @@ export async function loadInternalModule(args: {
if (loadedModule.discoveryPath) {
moduleResources = await loadResources({
container,
moduleResolution: resolution,
discoveryPath: loadedModule.discoveryPath,
logger,
@@ -379,6 +385,7 @@ export async function loadInternalModule(args: {
}
export async function loadModuleMigrations(
container: MedusaContainer,
resolution: ModuleResolution,
moduleExports?: ModuleExports
): Promise<{
@@ -449,6 +456,7 @@ export async function loadModuleMigrations(
if (!runMigrationsCustom || !revertMigrationCustom) {
const moduleResources = await loadResources({
container,
moduleResolution: resolution,
discoveryPath: loadedModule.discoveryPath,
loadedModuleLoaders: loadedModule?.loaders,
@@ -536,17 +544,21 @@ async function importAllFromDir(path: string) {
return (
await Promise.all(filesToLoad.map((filePath) => dynamicImport(filePath)))
).flatMap((value) => {
return Object.values(value)
})
)
.filter((value) => !isFileSkipped(value))
.flatMap((value) => {
return Object.values(value)
})
}
export async function loadResources({
container,
moduleResolution,
discoveryPath,
logger,
loadedModuleLoaders,
}: {
container: MedusaContainer
moduleResolution: ModuleResolution
discoveryPath: string
logger?: Logger
@@ -566,8 +578,31 @@ export async function loadResources({
return []
}
const flagDir = resolve(normalizedPath)
const discovered = await discoverFeatureFlagsFromDir(flagDir, 1)
const configModule = container.resolve(
ContainerRegistrationKeys.CONFIG_MODULE,
{
allowUnregistered: true,
}
) as ConfigModule
for (const def of discovered) {
registerFeatureFlag({
flag: def,
projectConfigFlags: configModule?.featureFlags ?? {},
router: FeatureFlag,
logger,
})
}
const [moduleService, services, models, repositories] = await Promise.all([
dynamicImport(modulePath).then((moduleExports) => {
if (isFileSkipped(moduleExports)) {
return
}
const mod = moduleExports.default ?? moduleExports
return mod.service
}),

View File

@@ -1,10 +1,13 @@
import { RemoteFetchDataCallback } from "@medusajs/orchestration"
import {
ConfigModule,
ExternalModuleDeclaration,
FlagSettings,
IIndexService,
ILinkMigrationsPlanner,
InternalModuleDeclaration,
LoadedModule,
Logger,
MedusaContainer,
ModuleBootstrapDeclaration,
ModuleDefinition,
@@ -16,7 +19,9 @@ import {
import {
ContainerRegistrationKeys,
createMedusaContainer,
discoverFeatureFlagsFromDir,
dynamicImport,
FeatureFlag,
GraphQLUtils,
isObject,
isSharedConnectionSymbol,
@@ -26,6 +31,7 @@ import {
Modules,
ModulesSdkUtils,
promiseAll,
registerFeatureFlag,
} from "@medusajs/utils"
import { asValue } from "awilix"
import { Link } from "./link"
@@ -79,6 +85,7 @@ export async function loadModules(args: {
migrationOnly?: boolean
loaderOnly?: boolean
workerMode?: "shared" | "worker" | "server"
cwd?: string
}) {
const {
modulesConfig,
@@ -87,6 +94,7 @@ export async function loadModules(args: {
migrationOnly = false,
loaderOnly = false,
workerMode = "shared" as ModuleBootstrapOptions["workerMode"],
cwd,
} = args
const allModules = {} as any
@@ -155,6 +163,7 @@ export async function loadModules(args: {
migrationOnly,
loaderOnly,
workerMode,
cwd,
})) as LoadedModule[]
if (loaderOnly) {
@@ -285,7 +294,7 @@ export type MedusaAppOptions = {
sharedResourcesConfig?: SharedResources
loadedModules?: LoadedModule[]
servicesConfig?: ModuleJoinerConfig[]
modulesConfigPath?: string
medusaConfigPath?: string
modulesConfigFileName?: string
modulesConfig?: MedusaModuleConfig
linkModules?: RegisterModuleJoinerConfig | RegisterModuleJoinerConfig[]
@@ -296,13 +305,14 @@ export type MedusaAppOptions = {
* Forces the modules bootstrapper to only run the modules loaders and return prematurely
*/
loaderOnly?: boolean
cwd?: string
}
async function MedusaApp_({
sharedContainer,
sharedResourcesConfig,
servicesConfig,
modulesConfigPath,
medusaConfigPath,
modulesConfigFileName,
modulesConfig,
linkModules,
@@ -311,11 +321,32 @@ async function MedusaApp_({
migrationOnly = false,
loaderOnly = false,
workerMode = "shared",
cwd = process.cwd(),
}: MedusaAppOptions & {
migrationOnly?: boolean
} = {}): Promise<MedusaAppOutput> {
const sharedContainer_ = createMedusaContainer({}, sharedContainer)
const config = sharedContainer_.resolve(
ContainerRegistrationKeys.CONFIG_MODULE,
{
allowUnregistered: true,
}
) as ConfigModule
const logger = sharedContainer_.resolve(ContainerRegistrationKeys.LOGGER, {
allowUnregistered: true,
}) as Logger
const discovered = await discoverFeatureFlagsFromDir(cwd)
for (const def of discovered) {
registerFeatureFlag({
flag: def as FlagSettings,
projectConfigFlags: config?.featureFlags ?? {},
router: FeatureFlag,
logger,
})
}
const onApplicationShutdown = async () => {
await promiseAll([
MedusaModule.onApplicationShutdown(),
@@ -335,8 +366,7 @@ async function MedusaApp_({
modulesConfig ??
(
await dynamicImport(
modulesConfigPath ??
process.cwd() + (modulesConfigFileName ?? "/modules-config")
medusaConfigPath ?? cwd + (modulesConfigFileName ?? "/modules-config")
)
).default
@@ -391,6 +421,7 @@ async function MedusaApp_({
migrationOnly,
loaderOnly,
workerMode,
cwd,
})
if (loaderOnly) {
@@ -512,6 +543,7 @@ async function MedusaApp_({
container: sharedContainer,
options: moduleResolution.options,
moduleExports: moduleResolution.moduleExports as ModuleExports,
cwd,
}
if (action === "revert") {

View File

@@ -58,6 +58,7 @@ export type MigrationOptions = {
container?: MedusaContainer
options?: Record<string, any>
moduleExports?: ModuleExports
cwd?: string
}
export type ModuleBootstrapOptions = {
@@ -80,6 +81,7 @@ export type ModuleBootstrapOptions = {
*/
loaderOnly?: boolean
workerMode?: "shared" | "worker" | "server"
cwd?: string
}
export type LinkModuleBootstrapOptions = {
@@ -87,6 +89,7 @@ export type LinkModuleBootstrapOptions = {
declaration?: InternalModuleDeclaration
moduleExports?: ModuleExports
injectedDependencies?: Record<string, any>
cwd?: string
}
export type RegisterModuleJoinerConfig =
@@ -289,10 +292,12 @@ class MedusaModule {
migrationOnly,
loaderOnly,
workerMode,
cwd,
}: {
migrationOnly?: boolean
loaderOnly?: boolean
workerMode?: ModuleBootstrapOptions["workerMode"]
cwd?: string
}
): Promise<
{
@@ -303,6 +308,7 @@ class MedusaModule {
migrationOnly,
loaderOnly,
workerMode,
cwd,
})
}
@@ -330,6 +336,7 @@ class MedusaModule {
migrationOnly,
loaderOnly,
workerMode,
cwd,
}: ModuleBootstrapOptions): Promise<{
[key: string]: T
}> {
@@ -349,6 +356,7 @@ class MedusaModule {
migrationOnly,
loaderOnly,
workerMode,
cwd,
}
)
@@ -369,16 +377,18 @@ class MedusaModule {
protected static async bootstrap_<T>(
modulesOptions: Omit<
ModuleBootstrapOptions,
"migrationOnly" | "loaderOnly" | "workerMode"
"migrationOnly" | "loaderOnly" | "workerMode" | "cwd"
>[],
{
migrationOnly,
loaderOnly,
workerMode,
cwd = process.cwd(),
}: {
migrationOnly?: boolean
loaderOnly?: boolean
workerMode?: "shared" | "worker" | "server"
cwd?: string
}
): Promise<
{
@@ -464,12 +474,13 @@ class MedusaModule {
}
}
const moduleResolutions = registerMedusaModule(
const moduleResolutions = registerMedusaModule({
moduleKey,
modDeclaration!,
moduleDeclaration: modDeclaration!,
moduleExports,
moduleDefinition
)
definition: moduleDefinition,
cwd,
})
const logger_ =
container.resolve(ContainerRegistrationKeys.LOGGER, {
@@ -617,6 +628,7 @@ class MedusaModule {
declaration,
moduleExports,
injectedDependencies,
cwd,
}: LinkModuleBootstrapOptions): Promise<{
[key: string]: unknown
}> {
@@ -672,7 +684,8 @@ class MedusaModule {
const moduleResolutions = registerMedusaLinkModule(
moduleDefinition,
modDeclaration as InternalModuleDeclaration,
moduleExports
moduleExports,
cwd
)
const logger_ =
@@ -749,11 +762,16 @@ class MedusaModule {
moduleExports,
moduleKey,
modulePath,
cwd,
}: MigrationOptions): Promise<void> {
const moduleResolutions = registerMedusaModule(moduleKey, {
scope: MODULE_SCOPE.INTERNAL,
resolve: modulePath,
options,
const moduleResolutions = registerMedusaModule({
moduleKey,
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resolve: modulePath,
options,
},
cwd,
})
const logger_ =
@@ -765,6 +783,7 @@ class MedusaModule {
for (const mod in moduleResolutions) {
const { generateMigration } = await loadModuleMigrations(
container,
moduleResolutions[mod],
moduleExports
)
@@ -785,11 +804,16 @@ class MedusaModule {
moduleExports,
moduleKey,
modulePath,
cwd,
}: MigrationOptions): Promise<void> {
const moduleResolutions = registerMedusaModule(moduleKey, {
scope: MODULE_SCOPE.INTERNAL,
resolve: modulePath,
options,
const moduleResolutions = registerMedusaModule({
moduleKey,
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resolve: modulePath,
options,
},
cwd,
})
const logger_ =
@@ -801,6 +825,7 @@ class MedusaModule {
for (const mod in moduleResolutions) {
const { runMigrations } = await loadModuleMigrations(
container,
moduleResolutions[mod],
moduleExports
)
@@ -821,11 +846,16 @@ class MedusaModule {
moduleExports,
moduleKey,
modulePath,
cwd,
}: MigrationOptions): Promise<void> {
const moduleResolutions = registerMedusaModule(moduleKey, {
scope: MODULE_SCOPE.INTERNAL,
resolve: modulePath,
options,
const moduleResolutions = registerMedusaModule({
moduleKey,
moduleDeclaration: {
scope: MODULE_SCOPE.INTERNAL,
resolve: modulePath,
options,
},
cwd,
})
const logger_ =
@@ -837,6 +867,7 @@ class MedusaModule {
for (const mod in moduleResolutions) {
const { revertMigration } = await loadModuleMigrations(
container,
moduleResolutions[mod],
moduleExports
)