diff --git a/.eslintrc.js b/.eslintrc.js index 3f1fbde..846d775 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,6 +3,7 @@ module.exports = { env: { es6: true, jest: true, + node: true, }, extends: [ 'eslint:recommended', @@ -95,5 +96,11 @@ module.exports = { 'no-restricted-globals': 'off', }, }, + { + files: ['src/tools/**/*.ts'], + rules: { + 'no-restricted-globals': 'off', + }, + }, ], }; diff --git a/README.md b/README.md index a224c7e..ece9d8c 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,78 @@ 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 +``` + +Common usage examples: +```bash +# Basic usage +yarn bootstrap-config --key --output bootstrap-config.json + +# With custom SDK name (default is 'js-client-sdk') +yarn bootstrap-config --key --sdk android + +# With custom base URL +yarn bootstrap-config --key --base-url https://api.custom-domain.com + +# Output configuration to stdout +yarn bootstrap-config --key + +# 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: 'js-client-sdk') +- `--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', + { + sdkName: 'android', // optional: target SDK name (default: 'js-client-sdk') + baseUrl: 'https://api.custom-domain.com', // optional: custom base URL + }); + + // Fetch the configuration + const config = await helper.fetchConfiguration(); + const configString = config.toString(); + + // You are responsible to transport this string to the client + const clientInitialData = {eppoConfig: eppoConfigString}; + + // Client-side + const client = getInstance(); + const initialConfig = configurationFromString(clientInitialData.eppoConfig); + client.setInitialConfig(configurationFromString(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 313b1c7..99fd689 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" }, "jsdelivr": "dist/eppo-sdk.js", "repository": { @@ -76,7 +77,8 @@ "pino": "^9.5.0", "semver": "^7.5.4", "spark-md5": "^3.0.2", - "uuid": "^11.0.5" + "uuid": "^11.0.5", + "yargs": "^17.7.2" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/src/configuration-wire/configuration-wire-helper.spec.ts b/src/configuration-wire/configuration-wire-helper.spec.ts index c3cc17e..4b1c768 100644 --- a/src/configuration-wire/configuration-wire-helper.spec.ts +++ b/src/configuration-wire/configuration-wire-helper.spec.ts @@ -19,7 +19,7 @@ describe('ConfigurationWireHelper', () => { baseUrl: TEST_BASE_URL, }); - const wirePacket = await helper.fetchBootstrapConfiguration(); + const wirePacket = await helper.fetchConfiguration(); expect(wirePacket.version).toBe(1); expect(wirePacket.config).toBeDefined(); @@ -47,9 +47,10 @@ describe('ConfigurationWireHelper', () => { sdkName: 'node-server', sdkVersion: '4.0.0', baseUrl: TEST_BASE_URL, + fetchBandits: true, }); - const wirePacket = await helper.fetchBootstrapConfiguration(); + const wirePacket = await helper.fetchConfiguration(); expect(wirePacket.version).toBe(1); expect(wirePacket.config).toBeDefined(); @@ -76,12 +77,12 @@ describe('ConfigurationWireHelper', () => { it('should include fetchedAt timestamps', async () => { const helper = ConfigurationWireHelper.build(BANDIT_SDK_KEY, { - sdkName: 'android', - sdkVersion: '4.0.0', + sdkName: 'node-server', baseUrl: TEST_BASE_URL, + fetchBandits: true, }); - const wirePacket = await helper.fetchBootstrapConfiguration(); + const wirePacket = await helper.fetchConfiguration(); if (!wirePacket.config) { throw new Error('Flag config not present in ConfigurationWire'); diff --git a/src/configuration-wire/configuration-wire-helper.ts b/src/configuration-wire/configuration-wire-helper.ts index 189c9c3..4a4eb7e 100644 --- a/src/configuration-wire/configuration-wire-helper.ts +++ b/src/configuration-wire/configuration-wire-helper.ts @@ -9,9 +9,10 @@ import SdkTokenDecoder from '../sdk-token-decoder'; import { ConfigurationWireV1, IConfigurationWire } from './configuration-wire-types'; export type SdkOptions = { - sdkName: string; - sdkVersion: string; + sdkName?: string; + sdkVersion?: string; baseUrl?: string; + fetchBandits?: boolean; }; /** @@ -23,25 +24,28 @@ export class ConfigurationWireHelper { /** * Build a new ConfigurationHelper for the target SDK Key. * @param sdkKey + * @param opts */ public static build( sdkKey: string, - opts: SdkOptions = { sdkName: 'android', sdkVersion: '4.0.0' }, + opts: SdkOptions = { sdkName: 'js-client-sdk', sdkVersion: '4.0.0' }, ) { - const { sdkName, sdkVersion, baseUrl } = opts; - return new ConfigurationWireHelper(sdkKey, sdkName, sdkVersion, baseUrl); + const { sdkName, sdkVersion, baseUrl, fetchBandits } = opts; + return new ConfigurationWireHelper(sdkKey, sdkName, sdkVersion, baseUrl, fetchBandits); } private constructor( sdkKey: string, - targetSdkName = 'android', + targetSdkName = 'js-client-sdk', targetSdkVersion = '4.0.0', baseUrl?: string, + private readonly fetchBandits = false, ) { const queryParams = { sdkName: targetSdkName, sdkVersion: targetSdkVersion, apiKey: sdkKey, + sdkProxy: 'config-wire-helper', }; const apiEndpoints = new ApiEndpoints({ baseUrl, @@ -56,7 +60,7 @@ export class ConfigurationWireHelper { * Fetches configuration data from the API and build a Bootstrap Configuration (aka an `IConfigurationWire` object). * The IConfigurationWire instance can be used to bootstrap some SDKs. */ - public async fetchBootstrapConfiguration(): Promise { + public async fetchConfiguration(): Promise { // Get the configs let banditResponse: IBanditParametersResponse | undefined; const configResponse: IUniversalFlagConfigResponse | undefined = @@ -68,7 +72,7 @@ export class ConfigurationWireHelper { } const flagsHaveBandits = Object.keys(configResponse.banditReferences ?? {}).length > 0; - if (flagsHaveBandits) { + if (this.fetchBandits && flagsHaveBandits) { banditResponse = await this.httpClient.getBanditParameters(); } diff --git a/src/configuration-wire/configuration-wire-types.ts b/src/configuration-wire/configuration-wire-types.ts index 9d1a8d2..d523652 100644 --- a/src/configuration-wire/configuration-wire-types.ts +++ b/src/configuration-wire/configuration-wire-types.ts @@ -200,6 +200,10 @@ export class ConfigurationWireV1 implements IConfigurationWire { readonly bandits?: IConfigResponse, ) {} + public toString(): string { + return JSON.stringify(this as IConfigurationWire); + } + public static fromResponses( flagConfig: IUniversalFlagConfigResponse, banditConfig?: IBanditParametersResponse, diff --git a/src/tools/commands/bootstrap-config.ts b/src/tools/commands/bootstrap-config.ts new file mode 100644 index 0000000..32db56f --- /dev/null +++ b/src/tools/commands/bootstrap-config.ts @@ -0,0 +1,72 @@ +import * as fs from 'fs'; + +import type { CommandModule } from 'yargs'; + +import { ConfigurationWireHelper } from '../../configuration-wire/configuration-wire-helper'; + +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: 'js-client-sdk', + }, + '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, + baseUrl: argv['base-url'] as string, + fetchBandits: true, + }); + const config = await helper.fetchConfiguration(); + + if (!config) { + console.error('Error: Failed to fetch configuration'); + process.exit(1); + } + + const jsonConfig = JSON.stringify(config, null, 2); + + 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..7b63c00 --- /dev/null +++ b/src/tools/get-bootstrap-config.ts @@ -0,0 +1,27 @@ +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; + +import { bootstrapConfigCommand } from './commands/bootstrap-config'; + +/** + * Script to run the bootstrap-config command directly. + * + * For usage, run: `ts-node src/tools/get-bootstrap-config.ts --help` + */ +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[]; +}; diff --git a/yarn.lock b/yarn.lock index 9b61f64..a7d159a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5081,7 +5081,7 @@ yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^17.3.1: +yargs@^17.3.1, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==