src/scripts/build/nimbusTypes.js (177 lines of code) (raw):

/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { mkdir, readFile, writeFile } from "node:fs/promises"; import { parse } from "yaml"; import * as prettier from "prettier"; run(); /** * See https://experimenter.info/fml-spec/#additional-types * * @typedef { "String" | "Boolean" | "Int" | "Text" | "Image" } BaseType * @typedef { BaseType | `Option<${BaseType}>` | `List<${BaseType}>` | `Map<${BaseType}, ${BaseType}>` } Type */ /** * @typedef {"local" | "staging" | "production"} Channel */ /** * @typedef {Record< * string, * { * description: string; * type: Type; * default: unknown; * "string-alias"?: string; * } * >} Variables */ /** * @typedef {{ * about: { description: string; }; * channels: Channel[]; * features: Record<string, { * description: string; * variables: Variables; * defaults?: Array<{ * channel: Channel; * value: Record<keyof Variables, unknown>; * }>; * }>; * enums?: Record<string, { * description: string; * variants: Record<string, string>; * }>; * objects?: Record<string, { * description: string; * fields: Record<string, { * description: string; * type: Type; * default: unknown; * }>; * }>; * }} NimbusConfig */ async function run() { const nimbusConfigSource = await readFile("config/nimbus.yaml", "utf-8"); /** @type {NimbusConfig} */ const nimbusConfig = parse(nimbusConfigSource); const typedef = "/* This Source Code Form is subject to the terms of the Mozilla Public\n" + " * License, v. 2.0. If a copy of the MPL was not distributed with this\n" + " * file, You can obtain one at http://mozilla.org/MPL/2.0/. */\n\n" + "// AUTOGENERATED `npm run build-nimbus`. DO NOT EDIT. DO NOT COMMIT.\n\n" + getFeaturesTypeDef(nimbusConfig) + "\n" + getFallbackObject(nimbusConfig) + "\n" + getLocalOverrides(nimbusConfig); await mkdir("src/telemetry/generated/nimbus/", { recursive: true }); await writeFile( "src/telemetry/generated/nimbus/experiments.ts", await prettier.format(typedef, { parser: "typescript" }), ); } /** * @param {NimbusConfig} nimbusConfig * @returns string */ function getFallbackObject(nimbusConfig) { const featureFallbackDefs = Object.keys(nimbusConfig.features).map( (featureId) => { const variableNames = Object.keys( nimbusConfig.features[featureId].variables, ); const variableFallbackDefs = variableNames.map((variableName) => { const variableValue = nimbusConfig.features[featureId].variables[variableName].default; return ` "${variableName}": ${typeof variableValue === "string" ? `"${variableValue}"` : variableValue},\n`; }); return ` "${featureId}": {\n${variableFallbackDefs.join("")} },\n`; }, ); const defaultExperimentData = ` Features: { ${featureFallbackDefs.join("\n")} }, Enrollments: [ { nimbus_user_id: "-1", app_id: "-1", experiment: "-1", branch: "-1", experiment_type: "-1", is_preview: false } ]`; return `export const defaultExperimentData: ExperimentData = {\n${defaultExperimentData}};\n`; } /** * @param {NimbusConfig} nimbusConfig * @returns string */ function getLocalOverrides(nimbusConfig) { const featureLocalOverridesDefs = Object.keys(nimbusConfig.features).map( (featureId) => { const localOverrides = nimbusConfig.features[featureId].defaults?.find( (defaultData) => defaultData.channel === "local", ); const overriddenValuesDef = typeof localOverrides === "undefined" ? "" : ` ...${JSON.stringify(localOverrides.value, null, 2).replaceAll("\n", "\n ")}\n`; return ` "${featureId}": {\n ...defaultExperimentData["Features"]["${featureId}"],\n${overriddenValuesDef} },\n`; }, ); const localExperimentData = ` Features: { ${featureLocalOverridesDefs.join("\n")} }, Enrollments: [ { nimbus_user_id: "-1", app_id: "-1", experiment: "-1", branch: "-1", experiment_type: "-1", is_preview: false } ]`; return `export const localExperimentData: ExperimentData = {\n${localExperimentData}};\n`; } /** * @param {NimbusConfig} nimbusConfig * @returns string */ function getFeaturesTypeDef(nimbusConfig) { const featureDefs = Object.keys(nimbusConfig.features).map((featureId) => { const variableNames = Object.keys( nimbusConfig.features[featureId].variables, ); const variableDefs = variableNames.map((variableName) => { return ` "${variableName}": ${getTypeScriptType(nimbusConfig.features[featureId].variables[variableName].type)};\n`; }); return ` "${featureId}": {\n${variableDefs.join("")} };\n`; }); const experimentDataType = `{ Features: {${featureDefs.join("")}}; Enrollments: Array<{ nimbus_user_id: string, app_id: string, experiment: string, branch: string, experiment_type: string, is_preview: boolean }>; };`; const experimentDataTypeDef = `/** Status of experiments, as setup in Experimenter */\nexport type ExperimentData = ${experimentDataType}`; const featureTypeDef = getStringAliases(nimbusConfig) + "\n\n" + getTypeAliases(nimbusConfig) + "\n\n" + experimentDataTypeDef; return featureTypeDef; } /** * @param {NimbusConfig} nimbusConfig * @returns string */ function getStringAliases(nimbusConfig) { const features = Object.values(nimbusConfig.features); const stringAliasDefs = features.flatMap((feature) => { const variablesWithStringAlias = Object.values(feature.variables).filter( (variable) => { return typeof variable["string-alias"] === "string"; }, ); return variablesWithStringAlias.map( (variable) => `type ${variable["string-alias"]} = string;\n`, ); }); return `/* Nimbus string aliases */\n${stringAliasDefs.join("")}`; } /** * @param {NimbusConfig} nimbusConfig * @returns {string} */ function getTypeAliases(nimbusConfig) { const objects = nimbusConfig.objects ?? {}; const objectDefs = Object.keys(objects).map((typeAlias) => { const propertyNames = Object.keys(objects[typeAlias].fields); const propertyDefs = propertyNames.map((propertyName) => { // TODO: Add descriptions as TSDoc comment? return ` "${propertyName}": ${getTypeScriptType(objects[typeAlias].fields[propertyName].type)};\n`; }); // TODO: Add description as TSDoc comment? return `type ${typeAlias} = {\n${propertyDefs.join("")}};\n`; }); const enums = nimbusConfig.enums ?? {}; const enumDefs = Object.keys(enums).map((typeAlias) => { // TODO: Add values as TSDoc comment? const unionOfStrings = Object.keys(enums[typeAlias].variants) .map((variant) => `"${variant}"`) .join(" | "); return `type ${typeAlias} = ${unionOfStrings};`; }); return ( "/* Nimbus object types */\n" + objectDefs.join("\n") + "\n\n/* Nimbus enum types */\n" + enumDefs.join("\n") ); } /** * @param {string} type * @returns {string} type */ function getTypeScriptType(type) { if (type === "String") { return "string"; } if (type === "Boolean") { return "boolean"; } if (type === "Int") { return "number"; } if (type === "Text" || type === "Image") { return "string"; } if (type.startsWith("Option<")) { const t = type.substring("Option<".length, type.length - 1).trim(); return `null | ${getTypeScriptType(t)}`; } if (type.startsWith("List<")) { const t = type.substring("List<".length, type.length - 1).trim(); return `Array<${getTypeScriptType(t)}>`; } if (type.startsWith("Map<")) { const kv = type.substring("Map<".length, type.length - 1).trim(); const [k, v] = kv.split(",").map((part) => part.trim()); return `Record<${getTypeScriptType(k)}, ${getTypeScriptType(v)}>`; } return type; }