UNPKG

hypertune

Version:

[Hypertune](https://www.hypertune.com/) is the most flexible platform for feature flags, A/B testing, analytics and app configuration. Built with full end-to-end type-safety, Git-style version control and local, synchronous, in-memory flag evaluation. Opt

254 lines (230 loc) 7.06 kB
/* eslint-disable no-underscore-dangle */ import JoyCon from "joycon"; type ValueType = "string" | "boolean" | "number" | "any"; export type Schema = { [optionName: string]: ValueType; }; /** * Handler for a CLI command. Given `Options`, it asynchronously returns a `Result` */ export type Handler<Options extends object, Result> = ( options: Options ) => Promise<Result>; /** * A handler decorator, that given a handler will return a new handler, with some extra functionality */ export type Wrapper = <Options extends object, Result>( handler: Handler<Options, Result>, schema?: Schema ) => Handler<Options, Result>; /** * Processes default options behavior: * - removes `--` option as we never use it * - file options (e.g. hypertune.config.js, hypertune.json, hypertune key in package.json) * - environment variable options (e.g. HYPERTUNE_TOKEN) * - any options passed in to the returned handler (i.e. CLI args) */ // eslint-disable-next-line func-style export const withOtherOptionSources: Wrapper = (handler, schema) => { // In ascending priority order // i.e. if an option is defined by wrapper[1], the same option defined by wrapper[0] will be ignored // Passing in arguments to the returned handler has the highest priority const wrappers = [ withoutDoubleDashOption, withFileOptions, withEnvVarOptions, ]; let wrapped = handler; for (const wrapper of wrappers) { wrapped = wrapper(wrapped, schema); } return wrapped; }; // eslint-disable-next-line func-style const withoutDoubleDashOption: Wrapper = (handler) => { return (options) => { if (typeof options === "object" && options !== null && "--" in options) { // eslint-disable-next-line no-param-reassign delete options["--"]; } return handler(options); }; }; // eslint-disable-next-line func-style const withFileOptions: Wrapper = (handler) => { return async (options) => { const joycon = new JoyCon({ files: [ "hypertune.config.js", "hypertune.config.cjs", "hypertune.json", "package.json", ], packageKey: "hypertune", }); const res = await joycon.load(); if (typeof res.data === "object" && res.data !== null) { return handler(mergeOptions(res.data, options)); } if (res.path) { console.warn( `Warning: Ignoring hypertune config at ${res.path}, as couldn't read it as a JavaScript object` ); } return handler(options); }; }; // eslint-disable-next-line func-style const withEnvVarOptions: Wrapper = (handler, schema) => { return (options) => { // eslint-disable-next-line no-constant-binary-expression if (typeof process !== undefined && process.env) { const envOptions = Object.fromEntries( Object.entries(process.env) .filter(([k]) => /^(.*_)?HYPERTUNE_/.test(k)) .map(([k, v]) => [envNameToOptionName(k), v]) .map(([k, v]) => [ k, schema && schema[k as string] ? parseOptionValueWithSchema(schema[k as string], v as string) : v, ]) ); return handler(mergeOptions(envOptions, options)); } return handler(options); }; }; function mergeOptions<T extends object>( options: object, overridingOptions: T ): T { const commonKeys = Object.keys(options).filter( (k) => k in overridingOptions ) as (keyof T)[]; commonKeys.forEach((k) => { console.warn( `Warning: option "${String( k )}" defined multiple times, using value ${JSON.stringify( overridingOptions[k] )}` ); }); return { ...options, ...overridingOptions }; } /** * Adds options validation to a handler based on a Zod schema. * * As Zod schemas can specify defaults, also changes the input type to what is actually required (e.g. okay not to provide something where it has a default). */ export function withValidation<Options extends object, HandlerResult>( schema: Schema, handler: Handler<Options, HandlerResult> ): Handler<Options, HandlerResult> { return (options) => { Object.entries(options).forEach(([option, value]) => { if (!(option in schema)) { console.warn(`Warning: Ignoring unrecognized option ${option}`); return; } switch (schema[option as string]) { case "any": return; case "string": if (typeof value !== "string") { throw new Error( `Option "${option}" must be a string, but ${typeof value} was provided` ); } return; case "boolean": if (typeof value !== "boolean") { throw new Error( `Option "${option}" must be a boolean, but ${typeof value} was provided` ); } return; case "number": if (typeof value !== "number") { throw new Error( `Option "${option}" must be a number, but ${typeof value} was provided` ); } return; default: throw new Error( `Unexpected option type "${schema[option as string]}" for option "${option}"` ); } }); return handler(options); }; } export function throwIfOptionIsUndefined<T>( optionName: string, value: T | undefined ): T { if (value === undefined) { throw new Error( `${optionName}: Missing required argument. Set it in your hypertune config (such as hypertune.json) as ${optionName}, use the ${optionNameToCliFlag( optionName )} argument, or the ${optionNameToEnvName( optionName )} environment variable.` ); } return value; } export function parseOptionValueWithSchema( valueType: ValueType, value: string ): number | boolean | string { switch (valueType) { case "boolean": { switch (value.toLowerCase().trim()) { case "1": case "yes": case "true": return true; case "0": case "no": case "false": return false; default: return value; } } case "number": { if (/^-?\d+\.?\d*$/.test(value)) { return Number(value); } return value; } default: return value; } } /** * @example envNameToOptionName("NEXT_PUBLIC_HYPERTUNE_OUTPUT_FILE_PATH") == "outputFilePath" */ function envNameToOptionName(envName: string): string { const afterPrefix = envName.replace(/^(.*?_?)HYPERTUNE_/, ""); return afterPrefix .toLowerCase() .replace(/_+(.)/g, (_, char: string) => char.toUpperCase()); } /** * @example optionNameToEnvName("outputFilePath") == "HYPERTUNE_OUTPUT_FILE_PATH" */ function optionNameToEnvName(optionName: string): string { return `HYPERTUNE_${optionName .replace(/[A-Z]/g, (letter) => `_${letter}`) .toUpperCase()}`; } /** * @example optionNameToCliFlag("outputFilePath") == "--outputFilePath" */ function optionNameToCliFlag(optionName: string): string { return `--${optionName}`; }