Skip to content

feat: bootstrap init #247

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <sdkKey>
```

Common usage examples:
```bash
# Basic usage
yarn bootstrap-config --key <sdkKey>

# With custom SDK name (default is 'android')
yarn bootstrap-config --key <sdkKey> --sdk js-client

# With custom base URL
yarn bootstrap-config --key <sdkKey> --base-url https://api.custom-domain.com

# Save configuration to a file
yarn bootstrap-config --key <sdkKey> --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.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
38 changes: 38 additions & 0 deletions src/client/eppo-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
95 changes: 57 additions & 38 deletions src/configuration-requestor.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -27,6 +32,12 @@ export default class ConfigurationRequestor {
);
}

public setInitialConfiguration(configuration: IConfigurationWire): Promise<boolean> {
const flags = JSON.parse(configuration.config?.response ?? '{}');
const bandits = JSON.parse(configuration.bandits?.response ?? '{}');
return this.hydrateConfigurationStores(flags, bandits);
}

public isFlagConfigExpired(): Promise<boolean> {
return this.flagConfigurationStore.isExpired();
}
Expand All @@ -35,63 +46,71 @@ export default class ConfigurationRequestor {
return this.configuration;
}

private async hydrateConfigurationStores(
flagConfig: IUniversalFlagConfigResponse,
banditResponse?: IBanditParametersResponse,
): Promise<boolean> {
let banditVariationPacket: ConfigStoreHydrationPacket<BanditVariation[]> | undefined;
let banditModelPacket: ConfigStoreHydrationPacket<BanditParameters> | undefined;
const flagResponsePacket: ConfigStoreHydrationPacket<Flag> = {
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<void> {
const configResponse = await this.httpClient.getUniversalFlagConfiguration();
let banditResponse: IBanditParametersResponse | undefined;
if (!configResponse?.flags) {
return;
}

const flagResponsePacket: ConfigStoreHydrationPacket<Flag> = {
entries: configResponse.flags,
environment: configResponse.environment,
createdAt: configResponse.createdAt,
format: configResponse.format,
};

let banditVariationPacket: ConfigStoreHydrationPacket<BanditVariation[]> | undefined;
let banditModelPacket: ConfigStoreHydrationPacket<BanditParameters> | 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.
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/i-configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class StoreBackedConfiguration implements IConfiguration {
flagConfig: ConfigStoreHydrationPacket<Flag>,
banditVariationConfig?: ConfigStoreHydrationPacket<BanditVariation[]>,
banditModelConfig?: ConfigStoreHydrationPacket<BanditParameters>,
) {
): Promise<boolean> {
const didUpdateFlags = await hydrateConfigurationStore(this.flagConfigurationStore, flagConfig);
const promises: Promise<boolean>[] = [];
if (this.banditVariationConfigurationStore && banditVariationConfig) {
Expand Down
73 changes: 73 additions & 0 deletions src/tools/commands/bootstrap-config.ts
Original file line number Diff line number Diff line change
@@ -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 <sdkKey> or -k <sdkKey>');
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);
}
},
};
26 changes: 26 additions & 0 deletions src/tools/get-bootstrap-config.ts
Original file line number Diff line number Diff line change
@@ -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);
});
8 changes: 8 additions & 0 deletions src/tools/node-shim.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
Loading