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
text/typescript
/* 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}`;
}