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,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
}),