chore(framework): Move feature flags related resources and cleanup (#8297)
**What** cleanup and move the feature flag related resources to manage them. It also include some refactoring around loading and registering the flag as well as not relying on the `glob` package anymore plus some reorganization of the code itself FIXES FRMW-2625
This commit is contained in:
@@ -32,6 +32,12 @@
|
||||
"import": "./dist/database/index.js",
|
||||
"require": "./dist/database/index.js",
|
||||
"node": "./dist/database/index.js"
|
||||
},
|
||||
"./feature-flag": {
|
||||
"types": "./dist/feature-flags/index.d.ts",
|
||||
"import": "./dist/feature-flags/index.js",
|
||||
"require": "./dist/feature-flags/index.js",
|
||||
"node": "./dist/feature-flags/index.js"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
@@ -73,6 +79,7 @@
|
||||
"express-session": "^1.17.3",
|
||||
"ioredis": "^5.2.5",
|
||||
"ioredis-mock": "8.4.0",
|
||||
"medusa-telemetry": "^0.0.18",
|
||||
"morgan": "^1.9.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -856,7 +856,7 @@ export type ConfigModule = {
|
||||
*
|
||||
* :::
|
||||
*/
|
||||
featureFlags: Record<string, boolean | string>
|
||||
featureFlags: Record<string, boolean | string | Record<string, boolean>>
|
||||
}
|
||||
|
||||
export type PluginDetails = {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { FileSystem } from "@medusajs/utils"
|
||||
import { join } from "path"
|
||||
import { featureFlagsLoader } from "../feature-flag-loader"
|
||||
import { configManager } from "../../config"
|
||||
|
||||
const filesystem = new FileSystem(join(__dirname, "__ff-test__"))
|
||||
|
||||
const buildFeatureFlag = (
|
||||
key: string,
|
||||
defaultVal: string | boolean
|
||||
): string => {
|
||||
const snakeCaseKey = key.replace(/-/g, "_")
|
||||
|
||||
return `
|
||||
export default {
|
||||
description: "${key} descr",
|
||||
key: "${snakeCaseKey}",
|
||||
env_key: "MEDUSA_FF_${snakeCaseKey.toUpperCase()}",
|
||||
default_val: ${defaultVal},
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
describe("feature flags", () => {
|
||||
const OLD_ENV = { ...process.env }
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.resetModules()
|
||||
jest.clearAllMocks()
|
||||
|
||||
process.env = { ...OLD_ENV }
|
||||
await filesystem.cleanup()
|
||||
|
||||
configManager.loadConfig({
|
||||
projectConfig: {} as any,
|
||||
baseDir: filesystem.basePath,
|
||||
})
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
process.env = OLD_ENV
|
||||
await filesystem.cleanup()
|
||||
})
|
||||
|
||||
it("should load the flag from project", async () => {
|
||||
configManager.loadConfig({
|
||||
projectConfig: { featureFlags: { flag_1: false } },
|
||||
baseDir: filesystem.basePath,
|
||||
})
|
||||
|
||||
await filesystem.create("flags/flag-1.js", buildFeatureFlag("flag-1", true))
|
||||
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
})
|
||||
|
||||
it("should load a nested + simple flag from project", async () => {
|
||||
configManager.loadConfig({
|
||||
projectConfig: {
|
||||
featureFlags: { test: { nested: true }, simpletest: true },
|
||||
},
|
||||
baseDir: filesystem.basePath,
|
||||
})
|
||||
|
||||
await filesystem.create("flags/test.js", buildFeatureFlag("test", false))
|
||||
await filesystem.create(
|
||||
"flags/simpletest.js",
|
||||
buildFeatureFlag("simpletest", false)
|
||||
)
|
||||
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled({ test: "nested" })).toEqual(true)
|
||||
expect(flags.isFeatureEnabled("simpletest")).toEqual(true)
|
||||
})
|
||||
|
||||
it("should load the default feature flags", async () => {
|
||||
await filesystem.create(
|
||||
"flags/flag-1.js",
|
||||
buildFeatureFlag("flag-1", false)
|
||||
)
|
||||
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
})
|
||||
|
||||
it("should load the flag from env", async () => {
|
||||
process.env.MEDUSA_FF_FLAG_1 = "false"
|
||||
|
||||
await filesystem.create(
|
||||
"flags/flag-1.js",
|
||||
buildFeatureFlag("flag-1", false)
|
||||
)
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
})
|
||||
|
||||
it("should load mix of flags", async () => {
|
||||
configManager.loadConfig({
|
||||
projectConfig: { featureFlags: { flag_2: false } },
|
||||
baseDir: filesystem.basePath,
|
||||
})
|
||||
|
||||
process.env.MEDUSA_FF_FLAG_3 = "true"
|
||||
await filesystem.create(
|
||||
"flags/flag-1.js",
|
||||
buildFeatureFlag("flag-1", false)
|
||||
)
|
||||
await filesystem.create(
|
||||
"flags/flag-2.js",
|
||||
buildFeatureFlag("flag-2", false)
|
||||
)
|
||||
await filesystem.create(
|
||||
"flags/flag-3.js",
|
||||
buildFeatureFlag("flag-3", false)
|
||||
)
|
||||
|
||||
const flags = await featureFlagsLoader(join(filesystem.basePath, "flags"))
|
||||
|
||||
expect(flags.isFeatureEnabled("flag_1")).toEqual(false)
|
||||
expect(flags.isFeatureEnabled("flag_2")).toEqual(false)
|
||||
expect(flags.isFeatureEnabled("flag_3")).toEqual(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
ContainerRegistrationKeys,
|
||||
FlagRouter,
|
||||
isDefined,
|
||||
isObject,
|
||||
isString,
|
||||
isTruthy,
|
||||
objectFromStringPath,
|
||||
} from "@medusajs/utils"
|
||||
import { trackFeatureFlag } from "medusa-telemetry"
|
||||
import { join, normalize } from "path"
|
||||
import { logger } from "../logger"
|
||||
import { FlagSettings } from "./types"
|
||||
import { container } from "../container"
|
||||
import { asFunction } from "awilix"
|
||||
import { configManager } from "../config"
|
||||
import { readdir } from "fs/promises"
|
||||
|
||||
export const featureFlagRouter = new FlagRouter({})
|
||||
|
||||
container.register(
|
||||
ContainerRegistrationKeys.FEATURE_FLAG_ROUTER,
|
||||
asFunction(() => featureFlagRouter)
|
||||
)
|
||||
|
||||
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
|
||||
*/
|
||||
export async function featureFlagsLoader(
|
||||
sourcePath?: string
|
||||
): Promise<FlagRouter> {
|
||||
const { featureFlags: projectConfigFlags = {} } = configManager.config
|
||||
|
||||
if (!sourcePath) {
|
||||
return featureFlagRouter
|
||||
}
|
||||
|
||||
const flagDir = normalize(sourcePath)
|
||||
|
||||
await readdir(flagDir, { recursive: true, withFileTypes: true }).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 import(join(flagDir, file.name))
|
||||
const featureFlag = fileExports.default
|
||||
|
||||
if (!featureFlag) {
|
||||
return
|
||||
}
|
||||
|
||||
registerFlag(featureFlag, projectConfigFlags)
|
||||
return
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
return featureFlagRouter
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { isObject, isString } from "@medusajs/utils"
|
||||
import { FeatureFlagsResponse, IFlagRouter } from "./types"
|
||||
|
||||
export class FlagRouter implements IFlagRouter {
|
||||
private readonly flags: Record<string, boolean | Record<string, boolean>> = {}
|
||||
|
||||
constructor(flags: Record<string, boolean | Record<string, boolean>>) {
|
||||
this.flags = flags
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a feature flag is enabled.
|
||||
* There are two ways of using this method:
|
||||
* 1. `isFeatureEnabled("myFeatureFlag")`
|
||||
* 2. `isFeatureEnabled({ myNestedFeatureFlag: "someNestedFlag" })`
|
||||
* We use 1. for top-level feature flags and 2. for nested feature flags. Almost all flags are top-level.
|
||||
* An example of a nested flag is workflows. To use it, you would do:
|
||||
* `isFeatureEnabled({ workflows: Workflows.CreateCart })`
|
||||
* @param flag - The flag to check
|
||||
* @return {boolean} - Whether the flag is enabled or not
|
||||
*/
|
||||
public isFeatureEnabled(
|
||||
flag: string | string[] | Record<string, string>
|
||||
): boolean {
|
||||
if (isObject(flag)) {
|
||||
const [nestedFlag, value] = Object.entries(flag)[0]
|
||||
if (typeof this.flags[nestedFlag] === "boolean") {
|
||||
return this.flags[nestedFlag] as boolean
|
||||
}
|
||||
return !!this.flags[nestedFlag]?.[value]
|
||||
}
|
||||
|
||||
const flags = (Array.isArray(flag) ? flag : [flag]) as string[]
|
||||
return flags.every((flag_) => {
|
||||
if (!isString(flag_)) {
|
||||
throw Error("Flag must be a string an array of string or an object")
|
||||
}
|
||||
return !!this.flags[flag_]
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a feature flag.
|
||||
* Flags take two shapes:
|
||||
* `setFlag("myFeatureFlag", true)`
|
||||
* `setFlag("myFeatureFlag", { nestedFlag: true })`
|
||||
* These shapes are used for top-level and nested flags respectively, as explained in isFeatureEnabled.
|
||||
* @param key - The key of the flag to set.
|
||||
* @param value - The value of the flag to set.
|
||||
* @return {void} - void
|
||||
*/
|
||||
public setFlag(
|
||||
key: string,
|
||||
value: boolean | { [key: string]: boolean }
|
||||
): void {
|
||||
if (isObject(value)) {
|
||||
const existing = this.flags[key]
|
||||
|
||||
if (!existing) {
|
||||
this.flags[key] = value
|
||||
return
|
||||
}
|
||||
|
||||
this.flags[key] = { ...(this.flags[key] as object), ...value }
|
||||
return
|
||||
}
|
||||
|
||||
this.flags[key] = value
|
||||
}
|
||||
|
||||
public listFlags(): FeatureFlagsResponse {
|
||||
return Object.entries(this.flags || {}).map(([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
}))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./feature-flag-loader"
|
||||
export * from "./flag-router"
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface IFlagRouter {
|
||||
isFeatureEnabled: (key: string) => boolean
|
||||
listFlags: () => FeatureFlagsResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @schema FeatureFlagsResponse
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* required:
|
||||
* - key
|
||||
* - value
|
||||
* properties:
|
||||
* key:
|
||||
* description: The key of the feature flag.
|
||||
* type: string
|
||||
* value:
|
||||
* description: The value of the feature flag.
|
||||
* type: boolean
|
||||
*/
|
||||
export type FeatureFlagsResponse = {
|
||||
key: string
|
||||
value: boolean | Record<string, boolean>
|
||||
}[]
|
||||
|
||||
export type FlagSettings = {
|
||||
key: string
|
||||
description: string
|
||||
env_key: string
|
||||
default_val: boolean
|
||||
}
|
||||
@@ -3,3 +3,4 @@ export * from "./logger"
|
||||
export * from "./http"
|
||||
export * from "./database"
|
||||
export * from "./container"
|
||||
export * from "./feature-flags"
|
||||
|
||||
Reference in New Issue
Block a user