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;
}