From 3c02ca8c8e545c29c0a99a1a834749e25cff31ce Mon Sep 17 00:00:00 2001 From: Assem Hafez Date: Wed, 15 Jan 2025 13:13:47 +0100 Subject: [PATCH] Dynamic config definitions and getter (#788) * dynamic config definitions and getter * enhance get transformed configs types and add unit tests * fix unit tests --- package-lock.json | 6 ++ package.json | 1 + src/config/dynamic/dynamic.config.ts | 47 +++++++++++ src/instrumentation.ts | 5 ++ .../config/__tests__/get-config-value.node.ts | 30 +++++++ .../config/__tests__/get-config-value.test.ts | 21 +++++ .../__tests__/get-transformed-configs.test.ts | 61 ++++++++++++++ src/utils/config/__tests__/global-ref.node.ts | 51 ++++++++++++ .../__tests__/transform-configs.test.ts | 39 +++++++++ src/utils/config/config.types.ts | 80 +++++++++++++++++++ src/utils/config/get-config-value.ts | 37 +++++++++ src/utils/config/get-transformed-configs.ts | 12 +++ src/utils/config/global-configs-ref.ts | 10 +++ src/utils/config/global-ref.ts | 15 ++++ src/utils/config/transform-configs.ts | 20 +++++ 15 files changed, 435 insertions(+) create mode 100644 src/config/dynamic/dynamic.config.ts create mode 100644 src/utils/config/__tests__/get-config-value.node.ts create mode 100644 src/utils/config/__tests__/get-config-value.test.ts create mode 100644 src/utils/config/__tests__/get-transformed-configs.test.ts create mode 100644 src/utils/config/__tests__/global-ref.node.ts create mode 100644 src/utils/config/__tests__/transform-configs.test.ts create mode 100644 src/utils/config/config.types.ts create mode 100644 src/utils/config/get-config-value.ts create mode 100644 src/utils/config/get-transformed-configs.ts create mode 100644 src/utils/config/global-configs-ref.ts create mode 100644 src/utils/config/global-ref.ts create mode 100644 src/utils/config/transform-configs.ts diff --git a/package-lock.json b/package-lock.json index 30dc0d07f..028d2a8aa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "react-intersection-observer": "^9.8.1", "react-json-view-lite": "^1.4.0", "react-virtuoso": "^4.10.4", + "server-only": "^0.0.1", "styletron-engine-monolithic": "^1.0.0", "styletron-react": "^6.1.1", "use-between": "^1.3.5", @@ -9650,6 +9651,11 @@ "node": ">=10" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==" + }, "node_modules/set-function-length": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", diff --git a/package.json b/package.json index ccaf042ac..39459527d 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "react-intersection-observer": "^9.8.1", "react-json-view-lite": "^1.4.0", "react-virtuoso": "^4.10.4", + "server-only": "^0.0.1", "styletron-engine-monolithic": "^1.0.0", "styletron-react": "^6.1.1", "use-between": "^1.3.5", diff --git a/src/config/dynamic/dynamic.config.ts b/src/config/dynamic/dynamic.config.ts new file mode 100644 index 000000000..4f1321946 --- /dev/null +++ b/src/config/dynamic/dynamic.config.ts @@ -0,0 +1,47 @@ +import 'server-only'; + +import type { + ConfigAsyncResolverDefinition, + ConfigEnvDefinition, + ConfigSyncResolverDefinition, +} from '../../utils/config/config.types'; + +const dynamicConfigs: { + CADENCE_WEB_PORT: ConfigEnvDefinition; + ADMIN_SECURITY_TOKEN: ConfigEnvDefinition; + GRPC_PROTO_DIR_BASE_PATH: ConfigEnvDefinition; + GRPC_SERVICES_NAMES: ConfigEnvDefinition; + COMPUTED: ConfigSyncResolverDefinition<[string], [string]>; + DYNAMIC: ConfigAsyncResolverDefinition; +} = { + CADENCE_WEB_PORT: { + env: 'CADENCE_WEB_PORT', + //Fallback to nextjs default port if CADENCE_WEB_PORT is not provided + default: '3000', + }, + ADMIN_SECURITY_TOKEN: { + env: 'CADENCE_ADMIN_SECURITY_TOKEN', + default: '', + }, + GRPC_PROTO_DIR_BASE_PATH: { + env: 'GRPC_PROTO_DIR_BASE_PATH', + default: 'src/__generated__/idl/proto', + }, + GRPC_SERVICES_NAMES: { + env: 'NEXT_PUBLIC_CADENCE_GRPC_SERVICES_NAMES', + default: 'cadence-frontend', + }, + // For testing purposes + DYNAMIC: { + resolver: async () => { + return 1; + }, + }, + COMPUTED: { + resolver: (value: [string]) => { + return value; + }, + }, +} as const; + +export default dynamicConfigs; diff --git a/src/instrumentation.ts b/src/instrumentation.ts index f80ee9863..82973eb1b 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,5 +1,10 @@ +import getTransformedConfigs from './utils/config/get-transformed-configs'; +import { setLoadedGlobalConfigs } from './utils/config/global-configs-ref'; import { registerLoggers } from './utils/logger'; export async function register() { registerLoggers(); + if (process.env.NEXT_RUNTIME === 'nodejs') { + setLoadedGlobalConfigs(getTransformedConfigs()); + } } diff --git a/src/utils/config/__tests__/get-config-value.node.ts b/src/utils/config/__tests__/get-config-value.node.ts new file mode 100644 index 000000000..29764a9ec --- /dev/null +++ b/src/utils/config/__tests__/get-config-value.node.ts @@ -0,0 +1,30 @@ +import { type LoadedConfigs } from '../config.types'; +import getConfigValue from '../get-config-value'; +import { loadedGlobalConfigs } from '../global-configs-ref'; + +jest.mock('../global-configs-ref', () => ({ + loadedGlobalConfigs: { + COMPUTED: jest.fn(), + CADENCE_WEB_PORT: 'someValue', + } satisfies Partial, +})); + +describe('getConfigValue', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the value directly if it is not a function', async () => { + const result = await getConfigValue('CADENCE_WEB_PORT'); + expect(result).toBe('someValue'); + }); + + it('calls the function with the provided argument and returns the result', async () => { + const mockFn = loadedGlobalConfigs.COMPUTED as jest.Mock; + mockFn.mockResolvedValue('resolvedValue'); + + const result = await getConfigValue('COMPUTED', ['arg']); + expect(mockFn).toHaveBeenCalledWith(['arg']); + expect(result).toBe('resolvedValue'); + }); +}); diff --git a/src/utils/config/__tests__/get-config-value.test.ts b/src/utils/config/__tests__/get-config-value.test.ts new file mode 100644 index 000000000..a0f9d1cf4 --- /dev/null +++ b/src/utils/config/__tests__/get-config-value.test.ts @@ -0,0 +1,21 @@ +import getConfigValue from '../get-config-value'; + +jest.mock('../global-configs-ref', () => ({ + loadedGlobalConfigs: { + CADENCE_WEB_PORT: 'someValue', + }, +})); + +describe('getConfigValue', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('throws an error when invoked in the browser', async () => { + (global as any).window = {}; + await expect(getConfigValue('CADENCE_WEB_PORT', undefined)).rejects.toThrow( + 'getConfigValue cannot be invoked on browser' + ); + delete (global as any).window; + }); +}); diff --git a/src/utils/config/__tests__/get-transformed-configs.test.ts b/src/utils/config/__tests__/get-transformed-configs.test.ts new file mode 100644 index 000000000..07f9af10e --- /dev/null +++ b/src/utils/config/__tests__/get-transformed-configs.test.ts @@ -0,0 +1,61 @@ +import { + type ConfigEnvDefinition, + type ConfigSyncResolverDefinition, +} from '../config.types'; +import transformConfigs from '../transform-configs'; + +describe('transformConfigs', () => { + const originalEnv = process.env; + beforeEach(() => { + jest.resetModules(); + process.env = { + ...originalEnv, + $$$_MOCK_ENV_CONFIG1: 'envValue1', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should add resolver function as is', () => { + const configDefinitions: { + config1: ConfigEnvDefinition; + config2: ConfigSyncResolverDefinition; + } = { + config1: { env: '$$$_MOCK_ENV_CONFIG1', default: 'default1' }, + config2: { + resolver: () => 'resolvedValue', + }, + }; + const result = transformConfigs(configDefinitions); + expect(result).toEqual({ + config1: 'envValue1', + config2: configDefinitions.config2.resolver, + }); + }); + + it('should return environment variable value when present', () => { + const configDefinitions: { + config1: ConfigEnvDefinition; + } = { + config1: { env: '$$$_MOCK_ENV_CONFIG1', default: 'default1' }, + }; + const result = transformConfigs(configDefinitions); + expect(result).toEqual({ + config1: 'envValue1', + }); + }); + + it('should return default value when environment variable is not present', () => { + const configDefinitions: { + config3: ConfigEnvDefinition; + } = { + config3: { env: '$$$_MOCK_ENV_CONFIG3', default: 'default3' }, + }; + const result = transformConfigs(configDefinitions); + expect(result).toEqual({ + config3: 'default3', + }); + }); +}); diff --git a/src/utils/config/__tests__/global-ref.node.ts b/src/utils/config/__tests__/global-ref.node.ts new file mode 100644 index 000000000..fcdf28215 --- /dev/null +++ b/src/utils/config/__tests__/global-ref.node.ts @@ -0,0 +1,51 @@ +import GlobalRef from '../global-ref'; + +describe('GlobalRef', () => { + let originalGlobal: any; + + beforeEach(() => { + originalGlobal = global; + global = { ...global }; + }); + + afterEach(() => { + global = originalGlobal; + }); + + it('should set and get the value correctly', () => { + const globalRef = new GlobalRef('test-unique-name'); + globalRef.value = 42; + expect(globalRef.value).toBe(42); + }); + + it('should return undefined if value is not set', () => { + const globalRef = new GlobalRef('another-unique-name'); + expect(globalRef.value).toBeUndefined(); + }); + + it('should handle different types of values', () => { + const stringRef = new GlobalRef('string-unique-name'); + stringRef.value = 'test string'; + expect(stringRef.value).toBe('test string'); + + const objectRef = new GlobalRef<{ key: string }>('object-unique-name'); + objectRef.value = { key: 'value' }; + expect(objectRef.value).toEqual({ key: 'value' }); + }); + + it('should use the same symbol for the same unique name', () => { + const ref1 = new GlobalRef('shared-unique-name'); + const ref2 = new GlobalRef('shared-unique-name'); + ref1.value = 100; + expect(ref2.value).toBe(100); + }); + + it('should use different symbols for different unique names', () => { + const ref1 = new GlobalRef('unique-name-1'); + const ref2 = new GlobalRef('unique-name-2'); + ref1.value = 100; + ref2.value = 200; + expect(ref1.value).toBe(100); + expect(ref2.value).toBe(200); + }); +}); diff --git a/src/utils/config/__tests__/transform-configs.test.ts b/src/utils/config/__tests__/transform-configs.test.ts new file mode 100644 index 000000000..7980ad016 --- /dev/null +++ b/src/utils/config/__tests__/transform-configs.test.ts @@ -0,0 +1,39 @@ +import { type ConfigEnvDefinition, type LoadedConfigs } from '../config.types'; +import { default as getTransformedConfigs } from '../get-transformed-configs'; + +type MockConfigDefinitions = { + config1: ConfigEnvDefinition; + config2: ConfigEnvDefinition; +}; +jest.mock( + '@/config/dynamic/dynamic.config', + () => + ({ + config1: { env: '$$$_MOCK_ENV_CONFIG1', default: 'default1' }, + config2: { env: '$$$_MOCK_ENV_CONFIG2', default: 'default2' }, + }) satisfies MockConfigDefinitions +); + +describe('getTransformedConfigs', () => { + const originalEnv = process.env; + beforeEach(() => { + jest.resetModules(); + process.env = { + ...originalEnv, + $$$_MOCK_ENV_CONFIG1: 'envValue1', + $$$_MOCK_ENV_CONFIG2: '', + }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return transformed dynamic configs', () => { + const result = getTransformedConfigs(); + expect(result).toEqual({ + config1: 'envValue1', + config2: 'default2', + } satisfies LoadedConfigs); + }); +}); diff --git a/src/utils/config/config.types.ts b/src/utils/config/config.types.ts new file mode 100644 index 000000000..ec1139a3e --- /dev/null +++ b/src/utils/config/config.types.ts @@ -0,0 +1,80 @@ +import { type z } from 'zod'; + +import type dynamicConfigs from '@/config/dynamic/dynamic.config'; + +export type ConfigAsyncResolverDefinition = { + resolver: (args: Args) => Promise; + // isPublic?: boolean; // would be implemented in upcoming PR +}; + +export type ConfigSyncResolverDefinition = { + resolver: (args: Args) => ReturnType; + // forceSync?: boolean; // would be replaced in upcoming PR + // isPublic?: boolean; // would be implemented in upcoming PR +}; + +export type ConfigEnvDefinition = { + env: string; + default: string; + // forceSync?: boolean; // would be replaced in upcoming PR + // isPublic?: boolean; // would be implemented in upcoming PR +}; + +export type ConfigDefinition = + | ConfigAsyncResolverDefinition + | ConfigSyncResolverDefinition + | ConfigEnvDefinition; + +export type ConfigDefinitionRecords = Record; + +type InferLoadedConfig> = { + [K in keyof T]: T[K] extends ConfigEnvDefinition + ? string // If it's an env definition, the value is a string + : T[K] extends ConfigSyncResolverDefinition + ? (args: Args) => ReturnType // If it's a sync resolver, it's a function with matching signature + : T[K] extends ConfigAsyncResolverDefinition + ? (args: Args) => Promise // If it's an async resolver, it's a promise-returning function + : never; // If it doesn't match any known type, it's never +}; + +export type LoadedConfigs< + C extends ConfigDefinitionRecords = typeof dynamicConfigs, +> = InferLoadedConfig; + +export type ArgOfConfigResolver = + LoadedConfigs[K] extends (args: any) => any + ? Parameters[0] + : undefined; + +export type LoadedConfigValue = + LoadedConfigs[K] extends (args: any) => any + ? ReturnType + : string; + +export type ConfigKeysWithArgs = { + [K in keyof LoadedConfigs]: LoadedConfigs[K] extends (args: undefined) => any + ? never + : LoadedConfigs[K] extends (args: any) => any + ? K + : never; +}[keyof LoadedConfigs]; + +export type ConfigKeysWithoutArgs = Exclude< + keyof LoadedConfigs, + ConfigKeysWithArgs +>; + +type ResolverType = + | ConfigSyncResolverDefinition + | ConfigAsyncResolverDefinition; + +export type InferResolverSchema> = { + [Key in keyof Definitions]: Definitions[Key] extends ResolverType< + infer Args, + infer ReturnType + > + ? { args: z.ZodType; returnType: z.ZodType } + : never; +}; + +export type ResolverSchemas = InferResolverSchema; diff --git a/src/utils/config/get-config-value.ts b/src/utils/config/get-config-value.ts new file mode 100644 index 000000000..0d0102a0b --- /dev/null +++ b/src/utils/config/get-config-value.ts @@ -0,0 +1,37 @@ +import type { + LoadedConfigValue, + LoadedConfigs, + ArgOfConfigResolver, + ConfigKeysWithArgs, + ConfigKeysWithoutArgs, +} from './config.types'; +import { loadedGlobalConfigs } from './global-configs-ref'; + +// Overload for keys requiring arguments +export default async function getConfigValue( + key: K, + arg: ArgOfConfigResolver +): Promise>; + +// Overload for keys not requiring arguments (env configs) +export default async function getConfigValue( + key: K, + arg?: undefined +): Promise>; + +export default async function getConfigValue( + key: K, + arg?: ArgOfConfigResolver +): Promise> { + if (typeof window !== 'undefined') { + throw new Error('getConfigValue cannot be invoked on browser'); + } + + const value = loadedGlobalConfigs[key] as LoadedConfigs[K]; + + if (typeof value === 'function') { + return (await value(arg)) as LoadedConfigValue; + } + + return value as LoadedConfigValue; +} diff --git a/src/utils/config/get-transformed-configs.ts b/src/utils/config/get-transformed-configs.ts new file mode 100644 index 000000000..777df4aba --- /dev/null +++ b/src/utils/config/get-transformed-configs.ts @@ -0,0 +1,12 @@ +import 'server-only'; + +import configDefinitions from '../../config/dynamic/dynamic.config'; + +import type { LoadedConfigs } from './config.types'; +import transformConfigs from './transform-configs'; + +export default function getTransformedConfigs(): LoadedConfigs< + typeof configDefinitions +> { + return transformConfigs(configDefinitions); +} diff --git a/src/utils/config/global-configs-ref.ts b/src/utils/config/global-configs-ref.ts new file mode 100644 index 000000000..33c7ae87b --- /dev/null +++ b/src/utils/config/global-configs-ref.ts @@ -0,0 +1,10 @@ +import { type LoadedConfigs } from './config.types'; +import GlobalRef from './global-ref'; + +const globalConfigRef = new GlobalRef('cadence-config'); +const setLoadedGlobalConfigs = (c: LoadedConfigs): void => { + globalConfigRef.value = c; +}; + +const loadedGlobalConfigs: LoadedConfigs = globalConfigRef.value; +export { loadedGlobalConfigs, setLoadedGlobalConfigs }; diff --git a/src/utils/config/global-ref.ts b/src/utils/config/global-ref.ts new file mode 100644 index 000000000..48f855592 --- /dev/null +++ b/src/utils/config/global-ref.ts @@ -0,0 +1,15 @@ +export default class GlobalRef { + private readonly sym: symbol; + + constructor(uniqueName: string) { + this.sym = Symbol.for(uniqueName); + } + + get value() { + return (global as any)[this.sym] as T; + } + + set value(value: T) { + (global as any)[this.sym] = value; + } +} diff --git a/src/utils/config/transform-configs.ts b/src/utils/config/transform-configs.ts new file mode 100644 index 000000000..1d19b2f39 --- /dev/null +++ b/src/utils/config/transform-configs.ts @@ -0,0 +1,20 @@ +import 'server-only'; + +import type { ConfigDefinitionRecords, LoadedConfigs } from './config.types'; + +export default function transformConfigs( + configDefinitions: C +): LoadedConfigs { + const resolvedConfig = Object.fromEntries( + Object.entries(configDefinitions).map(([key, definition]) => { + if ('resolver' in definition) { + return [key, definition.resolver]; + } + + const envValue = (process.env[definition.env] || '').trim(); + return [key, envValue === '' ? definition.default : envValue]; + }) + ); + + return resolvedConfig; +}