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:
committed by
GitHub
parent
4cda412243
commit
e413cfefc2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.`
|
||||
|
||||
@@ -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}.`)
|
||||
|
||||
@@ -86,7 +86,7 @@ export abstract class ResourceLoader {
|
||||
})
|
||||
|
||||
const resources = await promiseAll(promises)
|
||||
return resources.flat()
|
||||
return resources.flat().filter(Boolean)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}.`)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user