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,7 +1,7 @@
import { FileSystem } from "@medusajs/utils"
import { join } from "path"
import { featureFlagsLoader } from "../feature-flag-loader"
import { configManager } from "../../config"
import { featureFlagsLoader } from "../feature-flag-loader"
const filesystem = new FileSystem(join(__dirname, "__ff-test__"))
@@ -48,9 +48,12 @@ describe("feature flags", () => {
baseDir: filesystem.basePath,
})
await filesystem.create("flags/flag-1.js", buildFeatureFlag("flag-1", true))
await filesystem.create(
"feature-flags/flag-1.js",
buildFeatureFlag("flag-1", true)
)
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
const flags = await featureFlagsLoader(join(filesystem.basePath))
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
})
@@ -63,13 +66,16 @@ describe("feature flags", () => {
baseDir: filesystem.basePath,
})
await filesystem.create("flags/test.js", buildFeatureFlag("test", false))
await filesystem.create(
"flags/simpletest.js",
"feature-flags/test.js",
buildFeatureFlag("test", false)
)
await filesystem.create(
"feature-flags/simpletest.js",
buildFeatureFlag("simpletest", false)
)
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
const flags = await featureFlagsLoader(join(filesystem.basePath))
expect(flags.isFeatureEnabled({ test: "nested" })).toEqual(true)
expect(flags.isFeatureEnabled("simpletest")).toEqual(true)
@@ -77,11 +83,11 @@ describe("feature flags", () => {
it("should load the default feature flags", async () => {
await filesystem.create(
"flags/flag-1.js",
"feature-flags/flag-1.js",
buildFeatureFlag("flag-1", false)
)
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
const flags = await featureFlagsLoader(join(filesystem.basePath))
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
})
@@ -90,10 +96,10 @@ describe("feature flags", () => {
process.env.MEDUSA_FF_FLAG_1 = "false"
await filesystem.create(
"flags/flag-1.js",
"feature-flags/flag-1.js",
buildFeatureFlag("flag-1", false)
)
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
const flags = await featureFlagsLoader(join(filesystem.basePath))
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
})
@@ -106,19 +112,19 @@ describe("feature flags", () => {
process.env.MEDUSA_FF_FLAG_3 = "true"
await filesystem.create(
"flags/flag-1.js",
"feature-flags/flag-1.js",
buildFeatureFlag("flag-1", false)
)
await filesystem.create(
"flags/flag-2.js",
"feature-flags/flag-2.js",
buildFeatureFlag("flag-2", false)
)
await filesystem.create(
"flags/flag-3.js",
"feature-flags/flag-3.js",
buildFeatureFlag("flag-3", false)
)
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
const flags = await featureFlagsLoader(join(filesystem.basePath))
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
expect(flags.isFeatureEnabled("flag_2")).toEqual(false)

View File

@@ -1,85 +1,23 @@
import { trackFeatureFlag } from "@medusajs/telemetry"
import {
ContainerRegistrationKeys,
dynamicImport,
discoverFeatureFlagsFromDir,
FeatureFlag,
FlagRouter,
isDefined,
isObject,
isString,
isTruthy,
objectFromStringPath,
readDirRecursive,
registerFeatureFlag,
} from "@medusajs/utils"
import { asFunction } from "awilix"
import { join, normalize } from "path"
import { normalize } from "path"
import { configManager } from "../config"
import { container } from "../container"
import { logger } from "../logger"
import { FlagSettings } from "./types"
export const featureFlagRouter = new FlagRouter({})
container.register(
ContainerRegistrationKeys.FEATURE_FLAG_ROUTER,
asFunction(() => featureFlagRouter)
asFunction(() => FeatureFlag)
)
const excludedFiles = ["index.js", "index.ts"]
const excludedExtensions = [".d.ts", ".d.ts.map", ".js.map"]
const flagConfig: Record<string, boolean | Record<string, boolean>> = {}
function registerFlag(
flag: FlagSettings,
projectConfigFlags: Record<string, string | boolean | Record<string, boolean>>
) {
flagConfig[flag.key] = isTruthy(flag.default_val)
let from
if (isDefined(process.env[flag.env_key])) {
from = "environment"
const envVal = process.env[flag.env_key]
// MEDUSA_FF_ANALYTICS="true"
flagConfig[flag.key] = isTruthy(process.env[flag.env_key])
const parsedFromEnv = isString(envVal) ? envVal.split(",") : []
// MEDUSA_FF_WORKFLOWS=createProducts,deleteProducts
if (parsedFromEnv.length > 1) {
flagConfig[flag.key] = objectFromStringPath(parsedFromEnv)
}
} else if (isDefined(projectConfigFlags[flag.key])) {
from = "project config"
// featureFlags: { analytics: "true" | true }
flagConfig[flag.key] = isTruthy(
projectConfigFlags[flag.key] as string | boolean
)
// featureFlags: { workflows: { createProducts: true } }
if (isObject(projectConfigFlags[flag.key])) {
flagConfig[flag.key] = projectConfigFlags[flag.key] as Record<
string,
boolean
>
}
}
if (logger && from) {
logger.info(
`Using flag ${flag.env_key} from ${from} with value ${
flagConfig[flag.key]
}`
)
}
if (flagConfig[flag.key]) {
trackFeatureFlag(flag.key)
}
featureFlagRouter.setFlag(flag.key, flagConfig[flag.key])
}
/**
* Load feature flags from a directory and from the already loaded config under the hood
* @param sourcePath
@@ -90,39 +28,21 @@ export async function featureFlagsLoader(
const { featureFlags: projectConfigFlags = {} } = configManager.config
if (!sourcePath) {
return featureFlagRouter
return FeatureFlag
}
const flagDir = normalize(sourcePath)
await readDirRecursive(flagDir).then(async (files) => {
if (!files?.length) {
return
}
files.map(async (file) => {
if (file.isDirectory()) {
return await featureFlagsLoader(join(flagDir, file.name))
}
if (
excludedExtensions.some((ext) => file.name.endsWith(ext)) ||
excludedFiles.includes(file.name)
) {
return
}
const fileExports = await dynamicImport(join(flagDir, file.name))
const featureFlag = fileExports.default
if (!featureFlag) {
return
}
registerFlag(featureFlag, projectConfigFlags)
return
const discovered = await discoverFeatureFlagsFromDir(flagDir)
for (const def of discovered) {
registerFeatureFlag({
flag: def as FlagSettings,
projectConfigFlags,
router: FeatureFlag,
logger,
track: (key) => trackFeatureFlag(key),
})
})
}
return featureFlagRouter
return FeatureFlag
}

View File

@@ -35,12 +35,12 @@ export const createServer = async (rootDir) => {
const moduleResolutions = {}
Object.entries(ModulesDefinition).forEach(([moduleKey, module]) => {
moduleResolutions[moduleKey] = registerMedusaModule(
moduleResolutions[moduleKey] = registerMedusaModule({
moduleKey,
module.defaultModuleDeclaration,
undefined,
module
)[moduleKey]
moduleDeclaration: module.defaultModuleDeclaration,
moduleExports: undefined,
definition: module,
})[moduleKey]
})
configManager.loadConfig({

View File

@@ -1,15 +1,15 @@
import zod from "zod"
import { dynamicImport, FileSystem, isFileSkipped } from "@medusajs/utils"
import { join } from "path"
import { dynamicImport, FileSystem } from "@medusajs/utils"
import zod from "zod"
import { logger } from "../logger"
import {
type MiddlewaresConfig,
type BodyParserConfigRoute,
type MiddlewareDescriptor,
type MedusaErrorHandlerFunction,
type AdditionalDataValidatorRoute,
type BodyParserConfigRoute,
HTTP_METHODS,
type MedusaErrorHandlerFunction,
type MiddlewareDescriptor,
type MiddlewaresConfig,
} from "./types"
/**
@@ -51,6 +51,10 @@ export class MiddlewareFileLoader {
async #processMiddlewareFile(absolutePath: string): Promise<void> {
const middlewareExports = await dynamicImport(absolutePath)
if (isFileSkipped(middlewareExports)) {
return
}
const middlewareConfig = middlewareExports.default
if (!middlewareConfig) {
logger.warn(

View File

@@ -1,4 +1,4 @@
import { dynamicImport, readDirRecursive } from "@medusajs/utils"
import { dynamicImport, isFileSkipped, readDirRecursive } from "@medusajs/utils"
import { join, parse, sep } from "path"
import { logger } from "../logger"
import { HTTP_METHODS, type RouteDescriptor, type RouteVerb } from "./types"
@@ -92,6 +92,10 @@ export class RoutesLoader {
): Promise<RouteDescriptor[]> {
const routeExports = await dynamicImport(absolutePath)
if (isFileSkipped(routeExports)) {
return []
}
/**
* Find the route type based upon its prefix.
*/

View File

@@ -1,6 +1,6 @@
import type { SchedulerOptions } from "@medusajs/orchestration"
import { MedusaContainer } from "@medusajs/types"
import { isObject, MedusaError } from "@medusajs/utils"
import { isFileSkipped, isObject, MedusaError } from "@medusajs/utils"
import {
createStep,
createWorkflow,
@@ -31,6 +31,10 @@ export class JobLoader extends ResourceLoader {
config: CronJobConfig
}
) {
if (isFileSkipped(fileExports)) {
return
}
this.validateConfig(fileExports.config)
logger.debug(`Registering job from ${path}.`)
this.register({

View File

@@ -49,20 +49,29 @@ export class MedusaAppLoader {
| RegisterModuleJoinerConfig
| RegisterModuleJoinerConfig[]
readonly #medusaConfigPath?: string
readonly #cwd?: string
// TODO: Adjust all loaders to accept an optional container such that in test env it is possible if needed to provide a specific container otherwise use the main container
// Maybe also adjust the different places to resolve the config from the container instead of the configManager for the same reason
// To be discussed
constructor({
container,
customLinksModules,
medusaConfigPath,
cwd,
}: {
container?: MedusaContainer
customLinksModules?:
| RegisterModuleJoinerConfig
| RegisterModuleJoinerConfig[]
medusaConfigPath?: string
cwd?: string
} = {}) {
this.#container = container ?? mainContainer
this.#customLinksModules = customLinksModules ?? []
this.#medusaConfigPath = medusaConfigPath
this.#cwd = cwd
}
protected mergeDefaultModules(
@@ -172,6 +181,8 @@ export class MedusaAppLoader {
linkModules: this.#customLinksModules,
sharedResourcesConfig,
injectedDependencies,
medusaConfigPath: this.#medusaConfigPath,
cwd: this.#cwd,
}
if (action === "revert") {
@@ -197,6 +208,8 @@ export class MedusaAppLoader {
linkModules: this.#customLinksModules,
sharedResourcesConfig,
injectedDependencies,
medusaConfigPath: this.#medusaConfigPath,
cwd: this.#cwd,
}
return await MedusaAppGetLinksExecutionPlanner(migrationOptions)
@@ -217,6 +230,8 @@ export class MedusaAppLoader {
sharedResourcesConfig,
injectedDependencies,
loaderOnly: true,
medusaConfigPath: this.#medusaConfigPath,
cwd: this.#cwd,
})
}
@@ -255,6 +270,8 @@ export class MedusaAppLoader {
linkModules: this.#customLinksModules,
sharedResourcesConfig,
injectedDependencies,
medusaConfigPath: this.#medusaConfigPath,
cwd: this.#cwd,
})
if (!config.registerInContainer) {

View File

@@ -1,5 +1,5 @@
import { MedusaContainer } from "@medusajs/types"
import { dynamicImport, Modules } from "@medusajs/utils"
import { dynamicImport, isFileSkipped, Modules } from "@medusajs/utils"
import { basename } from "path"
import { logger } from "../logger"
import { Migrator } from "./migrator"
@@ -28,6 +28,10 @@ export class MigrationScriptsMigrator extends Migrator {
for (const script of scriptPaths) {
const scriptFn = await dynamicImport(script)
if (isFileSkipped(scriptFn)) {
continue
}
if (!scriptFn.default) {
throw new Error(
`Failed to load migration script ${script}. No default export found.`

View File

@@ -1,12 +1,12 @@
import { Event, IEventBusModuleService, Subscriber } from "@medusajs/types"
import { kebabCase, Modules } from "@medusajs/utils"
import { isFileSkipped, kebabCase, Modules } from "@medusajs/utils"
import { parse } from "path"
import { configManager } from "../config"
import { container } from "../container"
import { logger } from "../logger"
import { SubscriberArgs, SubscriberConfig } from "./types"
import { ResourceLoader } from "../utils/resource-loader"
import { SubscriberArgs, SubscriberConfig } from "./types"
type SubscriberHandler<T> = (args: SubscriberArgs<T>) => Promise<void>
@@ -42,6 +42,10 @@ export class SubscriberLoader extends ResourceLoader {
path: string,
fileExports: Record<string, unknown>
) {
if (isFileSkipped(fileExports)) {
return
}
const isValid = this.validateSubscriber(fileExports, path)
logger.debug(`Registering subscribers from ${path}.`)

View File

@@ -86,7 +86,7 @@ export abstract class ResourceLoader {
})
const resources = await promiseAll(promises)
return resources.flat()
return resources.flat().filter(Boolean)
}
/**

View File

@@ -1,3 +1,5 @@
import { isFileSkipped } from "@medusajs/utils"
import { MedusaWorkflow } from "@medusajs/workflows-sdk"
import { logger } from "../logger"
import { ResourceLoader } from "../utils/resource-loader"
@@ -12,6 +14,17 @@ export class WorkflowLoader extends ResourceLoader {
path: string,
fileExports: Record<string, unknown>
) {
if (isFileSkipped(fileExports)) {
const exportedFns = Object.keys(fileExports)
for (const exportedFn of exportedFns) {
const fn = fileExports[exportedFn] as any
if (fn?.getName?.()) {
MedusaWorkflow.unregisterWorkflow(fn.getName())
}
}
return
}
logger.debug(`Registering workflows from ${path}.`)
}