diff --git a/README.md b/README.md index a224c7e..136d9b3 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,74 @@ When publishing releases, the following rules apply: **Note**: The release will not be published if: - A pre-release is marked as "latest" - A pre-release label is used without checking "Set as pre-release" + +## Tools + +### Bootstrap Configuration + +You can generate a bootstrap configuration string from either the command line or programmatically via the +ConfigurationWireHelper class. + +The tool allows you to specify the target SDK this configuration will be used on. It is important to correctly specify +the intended SDK, as this determines whether the configuration is obfuscated (for client SDKs) or not (for server SDKs). + +#### Command Line Usage + +**Install as a project dependency:** +```bash +# Install as a dependency +npm install --save-dev @eppo/js-client-sdk-common +# or, with yarn +yarn add --dev @eppo/js-client-sdk-common + +# Or via yarn +yarn bootstrap-config --key +``` + +Common usage examples: +```bash +# Basic usage +yarn bootstrap-config --key + +# With custom SDK name (default is 'android') +yarn bootstrap-config --key --sdk js-client + +# With custom base URL +yarn bootstrap-config --key --base-url https://api.custom-domain.com + +# Save configuration to a file +yarn bootstrap-config --key --output bootstrap-config.json + +# Show help +yarn bootstrap-config --help +``` + +The tool accepts the following arguments: +- `--key, -k`: SDK key (required, can also be set via EPPO_SDK_KEY environment variable) +- `--sdk`: Target SDK name (default: 'android') +- `--base-url`: Custom base URL for the API +- `--output, -o`: Output file path (if not specified, outputs to console) +- `--help, -h`: Show help + +#### Programmatic Usage +```typescript +import { ConfigurationHelper } from '@eppo/js-client-sdk-common'; + +async function getBootstrapConfig() { + // Initialize the helper + const helper = ConfigurationHelper.build( + 'your-sdk-key', + 'js-client', // optional: target SDK name (default: 'android') + 'https://api.custom-domain.com' // optional: custom base URL + ); + + // Fetch the configuration + const configBuilder = await helper.getBootstrapConfigurationString(); + const configString = configBuilder.toString(); + + // Use the configuration string to initialize your SDK + console.log(configString); +} +``` + +The tool will output a JSON string containing the configuration wire format that can be used to bootstrap Eppo SDKs. diff --git a/package.json b/package.json index b150c68..e4d425c 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "typecheck": "tsc", "test": "yarn test:unit", "test:unit": "NODE_ENV=test jest '.*\\.spec\\.ts'", - "obfuscate-mock-ufc": "ts-node test/writeObfuscatedMockUFC" + "obfuscate-mock-ufc": "ts-node test/writeObfuscatedMockUFC", + "bootstrap-config": "ts-node src/tools/get-bootstrap-config.ts" }, "jsdelivr": "dist/eppo-sdk.js", "repository": { diff --git a/src/client/eppo-client.ts b/src/client/eppo-client.ts index a8f3a5d..0e587ba 100644 --- a/src/client/eppo-client.ts +++ b/src/client/eppo-client.ts @@ -309,6 +309,44 @@ export default class EppoClient { ); } + public bootstrap(configuration: IConfigurationWire) { + if (!this.configurationRequestParameters) { + throw new Error( + 'Eppo SDK unable to fetch flag configurations without configuration request parameters', + ); + } + // if fetchFlagConfigurations() was previously called, stop any polling process from that call + this.requestPoller?.stop(); + const { + apiKey, + sdkName, + sdkVersion, + baseUrl, // Default is set in ApiEndpoints constructor if undefined + requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS, + } = this.configurationRequestParameters; + + let { pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS } = this.configurationRequestParameters; + if (pollingIntervalMs <= 0) { + logger.error('pollingIntervalMs must be greater than 0. Using default'); + pollingIntervalMs = DEFAULT_POLL_INTERVAL_MS; + } + + // todo: Inject the chain of dependencies below + const apiEndpoints = new ApiEndpoints({ + baseUrl, + queryParams: { apiKey, sdkName, sdkVersion }, + }); + const httpClient = new FetchHttpClient(apiEndpoints, requestTimeoutMs); + const configurationRequestor = new ConfigurationRequestor( + httpClient, + this.flagConfigurationStore, + this.banditVariationConfigurationStore ?? null, + this.banditModelConfigurationStore ?? null, + ); + + configurationRequestor.setInitialConfiguration(configuration); + } + async fetchFlagConfigurations() { if (!this.configurationRequestParameters) { throw new Error( diff --git a/src/configuration-requestor.ts b/src/configuration-requestor.ts index 1f48e09..be6cf64 100644 --- a/src/configuration-requestor.ts +++ b/src/configuration-requestor.ts @@ -1,5 +1,10 @@ import { IConfigurationStore } from './configuration-store/configuration-store'; -import { IHttpClient } from './http-client'; +import { IConfigurationWire } from './configuration-wire/configuration-wire-types'; +import { + IBanditParametersResponse, + IHttpClient, + IUniversalFlagConfigResponse, +} from './http-client'; import { ConfigStoreHydrationPacket, IConfiguration, @@ -27,6 +32,12 @@ export default class ConfigurationRequestor { ); } + public setInitialConfiguration(configuration: IConfigurationWire): Promise { + const flags = JSON.parse(configuration.config?.response ?? '{}'); + const bandits = JSON.parse(configuration.bandits?.response ?? '{}'); + return this.hydrateConfigurationStores(flags, bandits); + } + public isFlagConfigExpired(): Promise { return this.flagConfigurationStore.isExpired(); } @@ -35,63 +46,71 @@ export default class ConfigurationRequestor { return this.configuration; } + private async hydrateConfigurationStores( + flagConfig: IUniversalFlagConfigResponse, + banditResponse?: IBanditParametersResponse, + ): Promise { + let banditVariationPacket: ConfigStoreHydrationPacket | undefined; + let banditModelPacket: ConfigStoreHydrationPacket | undefined; + const flagResponsePacket: ConfigStoreHydrationPacket = { + entries: flagConfig.flags, + environment: flagConfig.environment, + createdAt: flagConfig.createdAt, + format: flagConfig.format, + }; + + if (banditResponse) { + // Map bandit flag associations by flag key for quick lookup (instead of bandit key as provided by the UFC) + const banditVariations = this.indexBanditVariationsByFlagKey(flagConfig.banditReferences); + + banditVariationPacket = { + entries: banditVariations, + environment: flagConfig.environment, + createdAt: flagConfig.createdAt, + format: flagConfig.format, + }; + + if (banditResponse?.bandits) { + banditModelPacket = { + entries: banditResponse.bandits, + environment: flagConfig.environment, + createdAt: flagConfig.createdAt, + format: flagConfig.format, + }; + } + } + + return await this.configuration.hydrateConfigurationStores( + flagResponsePacket, + banditVariationPacket, + banditModelPacket, + ); + } + async fetchAndStoreConfigurations(): Promise { const configResponse = await this.httpClient.getUniversalFlagConfiguration(); + let banditResponse: IBanditParametersResponse | undefined; if (!configResponse?.flags) { return; } - const flagResponsePacket: ConfigStoreHydrationPacket = { - entries: configResponse.flags, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - - let banditVariationPacket: ConfigStoreHydrationPacket | undefined; - let banditModelPacket: ConfigStoreHydrationPacket | undefined; const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; const banditStoresProvided = Boolean( this.banditVariationConfigurationStore && this.banditModelConfigurationStore, ); if (flagsHaveBandits && banditStoresProvided) { - // Map bandit flag associations by flag key for quick lookup (instead of bandit key as provided by the UFC) - const banditVariations = this.indexBanditVariationsByFlagKey(configResponse.banditReferences); - - banditVariationPacket = { - entries: banditVariations, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - if ( this.requiresBanditModelConfigurationStoreUpdate( this.banditModelVersions, configResponse.banditReferences, ) ) { - const banditResponse = await this.httpClient.getBanditParameters(); - if (banditResponse?.bandits) { - banditModelPacket = { - entries: banditResponse.bandits, - environment: configResponse.environment, - createdAt: configResponse.createdAt, - format: configResponse.format, - }; - - this.banditModelVersions = this.getLoadedBanditModelVersions(banditResponse.bandits); - } + banditResponse = await this.httpClient.getBanditParameters(); } } - if ( - await this.configuration.hydrateConfigurationStores( - flagResponsePacket, - banditVariationPacket, - banditModelPacket, - ) - ) { + if (await this.hydrateConfigurationStores(configResponse, banditResponse)) { + this.banditModelVersions = this.getLoadedBanditModelVersions(this.configuration.getBandits()); // TODO: Notify that config updated. } } diff --git a/src/i-configuration.ts b/src/i-configuration.ts index f8d3b0d..9bd9eba 100644 --- a/src/i-configuration.ts +++ b/src/i-configuration.ts @@ -49,7 +49,7 @@ export class StoreBackedConfiguration implements IConfiguration { flagConfig: ConfigStoreHydrationPacket, banditVariationConfig?: ConfigStoreHydrationPacket, banditModelConfig?: ConfigStoreHydrationPacket, - ) { + ): Promise { const didUpdateFlags = await hydrateConfigurationStore(this.flagConfigurationStore, flagConfig); const promises: Promise[] = []; if (this.banditVariationConfigurationStore && banditVariationConfig) { diff --git a/src/tools/commands/bootstrap-config.ts b/src/tools/commands/bootstrap-config.ts new file mode 100644 index 0000000..d694f2f --- /dev/null +++ b/src/tools/commands/bootstrap-config.ts @@ -0,0 +1,73 @@ +import * as fs from 'fs'; + +import type { CommandModule } from 'yargs'; + +import { ConfigurationWireHelper } from '../../configuration-wire/configuration-wire-helper'; +import { process } from '../node-shim'; + +export const bootstrapConfigCommand: CommandModule = { + command: 'bootstrap-config', + describe: 'Generate a bootstrap configuration string', + builder: (yargs) => { + return yargs.options({ + key: { + type: 'string', + description: 'SDK key', + alias: 'k', + default: process.env.EPPO_SDK_KEY, + }, + sdk: { + type: 'string', + description: 'Target SDK name', + default: 'android', + }, + 'base-url': { + type: 'string', + description: 'Base URL for the API', + }, + output: { + type: 'string', + description: 'Output file path', + alias: 'o', + }, + }); + }, + handler: async (argv) => { + if (!argv.key) { + console.error('Error: SDK key is required'); + console.error('Provide it either as:'); + console.error('- Command line argument: --key or -k '); + console.error('- Environment variable: EPPO_SDK_KEY'); + process.exit(1); + } + + try { + const helper = ConfigurationWireHelper.build(argv.key as string, { + sdkName: argv.sdk as string, + sdkVersion: 'v5.0.0', + baseUrl: argv['base-url'] as string, + }); + const config = await helper.fetchBootstrapConfiguration(); + + if (!config) { + console.error('Error: Failed to fetch configuration'); + process.exit(1); + } + + const jsonConfig = JSON.stringify(config); + + if (argv.output && typeof argv.output === 'string') { + fs.writeFileSync(argv.output, jsonConfig); + console.log(`Configuration written to ${argv.output}`); + } else { + console.log('Configuration:'); + console.log('--------------------------------'); + console.log(jsonConfig); + console.log('--------------------------------'); + } + } catch (error) { + console.error('Error fetching configuration:', error); + process.exit(1); + } + }, +}; diff --git a/src/tools/get-bootstrap-config.ts b/src/tools/get-bootstrap-config.ts new file mode 100644 index 0000000..272dd61 --- /dev/null +++ b/src/tools/get-bootstrap-config.ts @@ -0,0 +1,26 @@ +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { bootstrapConfigCommand } from './commands/bootstrap-config'; +import { process } from './node-shim'; + +/** + * Script to run the bootstrap-config command directly. + */ +async function main() { + await yargs(hideBin(process.argv)) + .command({ + command: '$0', + describe: bootstrapConfigCommand.describe, + builder: bootstrapConfigCommand.builder, + handler: bootstrapConfigCommand.handler, + }) + .help() + .alias('help', 'h') + .parse(); +} + +main().catch((error) => { + console.error('Error in main:', error); + process.exit(1); +}); diff --git a/src/tools/node-shim.ts b/src/tools/node-shim.ts new file mode 100644 index 0000000..2c77736 --- /dev/null +++ b/src/tools/node-shim.ts @@ -0,0 +1,8 @@ +// Required type shape for `process`. +// We don't pin this project to node so eslint complains about the use of `process`. We declare a type shape here to +// appease the linter. +export declare const process: { + exit: (code: number) => void; + env: { [key: string]: string | undefined }; + argv: string[]; +};