@statezero/core
Version:
The type-safe frontend client for StateZero - connect directly to your backend models with zero boilerplate
624 lines (604 loc) • 24.1 kB
JavaScript
import axios from "axios";
import * as fs from "fs/promises";
import * as path from "path";
import cliProgress from "cli-progress";
import Handlebars from "handlebars";
import _ from "lodash-es";
import { z } from "zod";
import { configInstance } from "../../config.js";
import { loadConfigFromFile } from "../configFileLoader.js";
// ================================================================================================
// JSDOC TYPE DEFINITIONS
// ================================================================================================
/**
* @typedef {Object} ActionProperty
* @property {string} type
* @property {string} [format]
* @property {boolean} [required]
* @property {boolean} [nullable]
* @property {any} [default]
* @property {string[]} [choices]
* @property {number} [min_length]
* @property {number} [max_length]
* @property {number} [min_value]
* @property {number} [max_value]
* @property {ActionProperty} [items]
* @property {Object.<string, ActionProperty>} [properties]
* @property {string} [description]
*/
/**
* @typedef {Object} ActionDefinition
* @property {string} action_name
* @property {string} title
* @property {string} class_name
* @property {string | null} app - The application group for the action.
* @property {string | null} docstring - The action's documentation string.
* @property {Object.<string, ActionProperty>} input_properties
* @property {Object.<string, ActionProperty>} response_properties
* @property {string[]} permissions
*/
/**
* @typedef {Object} BackendConfig
* @property {string} NAME
* @property {string} API_URL
* @property {string} GENERATED_ACTIONS_DIR
*/
// ================================================================================================
// CLI INTERACTIVITY & FALLBACKS
// ================================================================================================
async function fallbackSelectAll(choices, message) {
console.log(`\n${message}`);
console.log("Interactive selection not available - generating ALL actions:");
const allActions = [];
for (const choice of choices) {
if (!choice.value) {
console.log(choice.name);
continue;
}
allActions.push(choice.value);
console.log(` ✓ ${choice.name}`);
}
console.log(`\nGenerating ALL ${allActions.length} actions.`);
return allActions;
}
async function selectActions(choices, message) {
try {
const inquirer = (await import("inquirer")).default;
const { selectedActions } = await inquirer.prompt([
{
type: "checkbox",
name: "selectedActions",
message,
choices,
pageSize: 20,
},
]);
return selectedActions;
}
catch (error) {
console.warn("Interactive selection failed, generating all available actions:", error.message);
return await fallbackSelectAll(choices, message);
}
}
// ================================================================================================
// HANDLEBARS TEMPLATES
// ================================================================================================
// Register a helper to format multi-line docstrings for JSDoc.
Handlebars.registerHelper("formatJsDoc", function (text) {
if (!text)
return "";
return text
.split("\n")
.map((line) => ` * ${line}`)
.join("\n");
});
const JS_ACTION_TEMPLATE = `/**
* This file was auto-generated. Do not make direct changes to the file.
* Action: {{title}}
* App: {{app}}
*/
import axios from 'axios';
import { z } from 'zod';
import { configInstance, parseStateZeroError } from '{{modulePath}}';
{{#if inputSchemaString}}
/**
* Zod schema for the input of {{functionName}}.
* NOTE: This is an object schema for validating the data payload.
*/
export const {{functionName}}InputSchema = z.object({ {{{inputSchemaString}}} });
{{/if}}
{{#if responseSchemaString}}
/**
* Zod schema for the response of {{functionName}}.
*/
export const {{functionName}}ResponseSchema = z.object({ {{{responseSchemaString}}} });
{{/if}}
/**
{{#if docstring}}
{{{formatJsDoc docstring}}}
*
{{else}}
* {{title}}
{{/if}}
{{#each tsDocParams}}
* @param {{{this.type}}} {{this.name}} - {{this.description}}
{{/each}}
* @param {Object} [axiosOverrides] - Allows overriding Axios request parameters.
* @returns {Promise<Object>} A promise that resolves with the action's result.
*/
export async function {{functionName}}({{{jsFunctionParams}}}) {
// Construct the data payload from the function arguments
{{#if payloadProperties}}
const payload = {
{{{payloadProperties}}}
};
{{else}}
const payload = {};
{{/if}}
const config = configInstance.getConfig();
const backend = config.backendConfigs['{{configKey}}'];
if (!backend) {
throw new Error(\`No backend configuration found for key: {{configKey}}\`);
}
const baseUrl = backend.API_URL.replace(/\\/+$/, '');
const actionUrl = \`\${baseUrl}/actions/{{actionName}}/\`;
const headers = backend.getAuthHeaders ? backend.getAuthHeaders() : {};
try {
const response = await axios.post(actionUrl, payload, {
headers: { 'Content-Type': 'application/json', ...headers },
...axiosOverrides,
});
{{#if responseSchemaString}}
return {{functionName}}ResponseSchema.parse(response.data);
{{else}}
return response.data;
{{/if}}
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(\`{{title}} failed: Invalid response from server. Details: \${error.message}\`);
}
if (error.response && error.response.data) {
const parsedError = parseStateZeroError(error.response.data);
if (Error.captureStackTrace) {
Error.captureStackTrace(parsedError, {{functionName}});
}
throw parsedError;
} else if (error.request) {
throw new Error(\`{{title}} failed: No response received from server.\`);
} else {
throw new Error(\`{{title}} failed: \${error.message}\`);
}
}
}
export default {{functionName}};
{{functionName}}.actionName = '{{actionName}}';
{{functionName}}.title = '{{title}}';
{{functionName}}.app = {{#if app}}'{{app}}'{{else}}null{{/if}};
{{functionName}}.permissions = [{{#each permissions}}'{{this}}'{{#unless @last}}, {{/unless}}{{/each}}];
{{functionName}}.configKey = '{{configKey}}';
`;
const TS_ACTION_DECLARATION_TEMPLATE = `/**
* This file was auto-generated. Do not make direct changes to the file.
* Action: {{title}}
* App: {{app}}
*/
import { z } from 'zod';
import { AxiosRequestConfig } from 'axios';
{{#if inputTsSchemaString}}
export type {{functionName}}Input = { {{inputTsSchemaString}} };
{{/if}}
{{#if responseTsSchemaString}}
export type {{functionName}}Response = { {{responseTsSchemaString}} };
{{else}}
export type {{functionName}}Response = any;
{{/if}}
/**
{{#if docstring}}
{{{formatJsDoc docstring}}}
*
{{else}}
* {{title}}
{{/if}}
{{#each tsDocParams}}
* @param {{{this.type}}} {{this.name}} - {{this.description}}
{{/each}}
* @param {AxiosRequestConfig} [axiosOverrides] - Allows overriding Axios request parameters.
* @returns {Promise<{{functionName}}Response>} A promise that resolves with the action's result.
*/
export declare function {{functionName}}(
{{{tsFunctionParams}}}
): Promise<{{functionName}}Response>;
export default {{functionName}};
export declare namespace {{functionName}} {
export const actionName: string;
export const title: string;
export const app: string | null;
export const permissions: string[];
export const configKey: string;
}
`;
const jsActionTemplate = Handlebars.compile(JS_ACTION_TEMPLATE);
const dtsActionTemplate = Handlebars.compile(TS_ACTION_DECLARATION_TEMPLATE);
// ================================================================================================
// SCHEMA TRANSLATION HELPERS
// ================================================================================================
function generateZodSchemaForProperty(prop) {
let zodString;
if (prop.choices && prop.choices.length > 0) {
zodString = `z.enum(${JSON.stringify(prop.choices)})`;
}
else {
switch (prop.type) {
case "string":
zodString = prop.max_digits
? 'z.string().regex(/^-?\\d+(\\.\\d+)?$/, "Must be a numeric string")'
: "z.string()";
break;
case "integer":
zodString = "z.number().int()";
break;
case "number":
zodString = "z.number()";
break;
case "boolean":
zodString = "z.boolean()";
break;
case "array":
const itemSchema = prop.items
? generateZodSchemaForProperty(prop.items)
: "z.any()";
zodString = `z.array(${itemSchema})`;
break;
case "object":
if (prop.properties) {
const nestedProps = Object.entries(prop.properties)
.map(([key, value]) => `${key}: ${generateZodSchemaForProperty(value)}`)
.join(", ");
zodString = `z.object({ ${nestedProps} })`;
}
else {
zodString = "z.record(z.any())";
}
break;
default:
zodString = "z.any()";
break;
}
}
if (prop.format) {
switch (prop.format) {
case "email":
zodString += ".email()";
break;
case "uri":
zodString += ".url()";
break;
case "uuid":
zodString += ".uuid()";
break;
case "date-time":
zodString +=
'.datetime({ message: "Invalid ISO 8601 datetime string" })';
break;
case "date":
zodString +=
'.regex(/^\\d{4}-\\d{2}-\\d{2}$/, "Must be in YYYY-MM-DD format")';
break;
case "time":
zodString +=
'.regex(/^\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?$/, "Must be in HH:MM:SS format")';
break;
}
}
if (prop.min_length != null)
zodString += `.min(${prop.min_length})`;
if (prop.max_length != null)
zodString += `.max(${prop.max_length})`;
if (prop.min_value != null)
zodString += `.min(${prop.min_value})`;
if (prop.max_value != null)
zodString += `.max(${prop.max_value})`;
if (prop.nullable)
zodString += ".nullable()";
if (!prop.required)
zodString += ".optional()";
if (prop.default !== undefined && prop.default !== null) {
zodString += `.default(${JSON.stringify(prop.default)})`;
}
return zodString;
}
function generateTsTypeForProperty(prop) {
if (prop.choices && prop.choices.length > 0) {
return prop.choices
.map((c) => `'${String(c).replace(/'/g, "\\'")}'`)
.join(" | ");
}
let tsType;
switch (prop.type) {
case "string":
tsType = "string";
break;
case "integer":
case "number":
tsType = "number";
break;
case "boolean":
tsType = "boolean";
break;
case "array":
tsType = prop.items
? `Array<${generateTsTypeForProperty(prop.items)}>`
: "any[]";
break;
case "object":
tsType = "Record<string, any>";
break;
default:
tsType = "any";
break;
}
if (prop.nullable) {
tsType = `${tsType} | null`;
}
return tsType;
}
// ================================================================================================
// DATA PREPARATION & FILE GENERATION
// ================================================================================================
function prepareActionTemplateData(modulePath, functionName, actionName, actionDefinition, configKey) {
const inputProps = actionDefinition.input_properties || {};
const responseProps = actionDefinition.response_properties || {};
const processProperties = (properties) => {
const propertyEntries = Object.entries(properties);
if (propertyEntries.length === 0)
return "";
const propertyStrings = propertyEntries
.map(([name, prop]) => ` ${name}: ${generateZodSchemaForProperty(prop)}`)
.join(",\n");
return `\n${propertyStrings}\n`;
};
// For TypeScript declarations, we need a different format
const processPropertiesForTS = (properties) => {
const propertyEntries = Object.entries(properties);
if (propertyEntries.length === 0)
return "";
const propertyStrings = propertyEntries
.map(([name, prop]) => `${name}: ${generateTsTypeForProperty(prop)}`)
.join(", ");
return propertyStrings;
};
const inputSchemaString = processProperties(inputProps);
const responseSchemaString = processProperties(responseProps);
// Generate TypeScript type strings for the .d.ts file
const inputTsSchemaString = processPropertiesForTS(inputProps);
const responseTsSchemaString = processPropertiesForTS(responseProps);
const requiredParams = [], optionalParams = [];
Object.entries(inputProps).forEach(([name, prop]) => {
(prop.required ? requiredParams : optionalParams).push({ name, prop });
});
const allParams = [...requiredParams, ...optionalParams];
const jsParams = allParams.map(({ name, prop }) => !prop.required && prop.default !== undefined && prop.default !== null
? `${name} = ${JSON.stringify(prop.default)}`
: name);
jsParams.push("axiosOverrides = {}");
const tsParams = allParams.map(({ name, prop }) => {
const type = generateTsTypeForProperty(prop);
const optionalMarker = prop.required ? "" : "?";
return `${name}${optionalMarker}: ${type}`;
});
tsParams.push(`axiosOverrides?: AxiosRequestConfig`);
const tsDocParams = allParams.map(({ name, prop }) => ({
name,
type: generateTsTypeForProperty(prop),
description: prop.description || `The ${name} parameter.`,
}));
return {
modulePath,
functionName,
actionName,
title: actionDefinition.title || _.startCase(functionName),
app: actionDefinition.app,
docstring: actionDefinition.docstring,
permissions: actionDefinition.permissions || [],
configKey,
inputSchemaString: inputSchemaString ? inputSchemaString.trim() : null,
responseSchemaString: responseSchemaString
? responseSchemaString.trim()
: null,
inputTsSchemaString: inputTsSchemaString || null,
responseTsSchemaString: responseTsSchemaString || null,
jsFunctionParams: jsParams.join(", "),
tsFunctionParams: tsParams.join(",\n "),
payloadProperties: Object.keys(inputProps).length > 0
? Object.keys(inputProps).join(",\n ")
: null,
tsDocParams: tsDocParams,
};
}
async function generateActionFile(backend, actionName, actionDefinition) {
const functionName = _.camelCase(actionName);
// Apply the same logic as syncModels.js
const modulePath = process.env.NODE_ENV === "test" ? "../../../src" : "@statezero/core";
const appName = (actionDefinition.app || "general").toLowerCase();
const outDir = path.join(backend.GENERATED_ACTIONS_DIR, appName);
await fs.mkdir(outDir, { recursive: true });
const templateData = prepareActionTemplateData(modulePath, functionName, actionName, actionDefinition, backend.NAME);
const fileName = _.kebabCase(actionName);
const jsFilePath = path.join(outDir, `${fileName}.js`);
await fs.writeFile(jsFilePath, jsActionTemplate(templateData));
const dtsFilePath = path.join(outDir, `${fileName}.d.ts`);
await fs.writeFile(dtsFilePath, dtsActionTemplate(templateData));
const relativePath = path
.relative(backend.GENERATED_ACTIONS_DIR, jsFilePath)
.replace(/\\/g, "/")
.replace(/\.js$/, "");
return {
action: actionName,
relativePath,
functionName,
backend: backend.NAME,
appName,
};
}
async function generateActionRegistry(generatedFiles, backendConfigs) {
const registryByBackend = {};
const allImports = new Set();
for (const file of generatedFiles) {
const backendKey = file.backend;
if (!backendKey)
continue;
registryByBackend[backendKey] = registryByBackend[backendKey] || {
actions: {},
};
const functionName = file.functionName;
const actionsDir = backendConfigs[backendKey].GENERATED_ACTIONS_DIR;
const importPath = path
.relative(process.cwd(), path.join(actionsDir, `${file.relativePath}.js`))
.replace(/\\/g, "/");
const importStatement = `import { ${functionName} } from './${importPath}';`;
allImports.add(importStatement);
registryByBackend[backendKey].actions[file.action] = functionName;
}
let registryContent = `/**
* This file was auto-generated. Do not make direct changes to the file.
* It provides a registry of all generated actions.
*/\n\n`;
registryContent += Array.from(allImports).sort().join("\n") + "\n\n";
registryContent += `export const ACTION_REGISTRY = {\n`;
Object.entries(registryByBackend).forEach(([backendKey, data], index, arr) => {
registryContent += ` '${backendKey}': {\n`;
const actionEntries = Object.entries(data.actions);
actionEntries.forEach(([actionName, funcName], idx, actionsArr) => {
registryContent += ` '${actionName}': ${funcName}${idx < actionsArr.length - 1 ? "," : ""}\n`;
});
registryContent += ` }${index < arr.length - 1 ? "," : ""}\n`;
});
registryContent += `};\n\n`;
registryContent += `export function getAction(actionName, configKey) {
const action = ACTION_REGISTRY[configKey]?.[actionName];
if (!action) {
console.warn(\`Action '\${actionName}' not found for config key '\${configKey}'.\`);
return null;
}
return action;
}\n`;
const registryFilePath = path.join(process.cwd(), "action-registry.js");
await fs.writeFile(registryFilePath, registryContent);
console.log(`\n✨ Generated action registry at ${registryFilePath}`);
}
async function generateAppLevelIndexFiles(generatedFiles, backendConfigs) {
const filesByBackend = _.groupBy(generatedFiles, "backend");
const indexTemplate = Handlebars.compile(`{{#each files}}
export * from '{{this.relativePath}}';
{{/each}}`);
for (const backendName in filesByBackend) {
const backendFiles = filesByBackend[backendName];
const backendConfig = backendConfigs[backendName];
const rootActionsDir = backendConfig.GENERATED_ACTIONS_DIR;
const rootExports = [];
const filesByApp = _.groupBy(backendFiles, "appName");
for (const appName in filesByApp) {
const appFiles = filesByApp[appName];
const appDir = path.join(rootActionsDir, appName);
const appIndexExports = appFiles.map((file) => {
const relativePathToAppDir = `./${path.basename(file.relativePath)}`;
return { ...file, relativePath: relativePathToAppDir };
});
const indexContent = indexTemplate({ files: appIndexExports }).trim();
await fs.writeFile(path.join(appDir, "index.js"), indexContent);
await fs.writeFile(path.join(appDir, "index.d.ts"), indexContent);
rootExports.push(`export * from './${appName}';`);
}
const rootIndexContent = rootExports.sort().join("\n");
await fs.writeFile(path.join(rootActionsDir, "index.js"), rootIndexContent);
await fs.writeFile(path.join(rootActionsDir, "index.d.ts"), rootIndexContent);
}
}
// ================================================================================================
// MAIN SCRIPT RUNNER
// ================================================================================================
async function main() {
loadConfigFromFile();
const configData = configInstance.getConfig();
const backendConfigs = configData.backendConfigs;
for (const [key, backend] of Object.entries(backendConfigs)) {
if (!backend.GENERATED_ACTIONS_DIR) {
console.error(`❌ Backend '${key}' is missing the GENERATED_ACTIONS_DIR configuration.`);
process.exit(1);
}
backend.NAME = key;
}
console.log("Fetching action schemas from backends...");
const fetchPromises = Object.values(backendConfigs).map(async (backend) => {
try {
const response = await axios.get(`${backend.API_URL}/actions-schema/`);
return { backend, actions: response.data.actions || {} };
}
catch (error) {
console.error(`❌ Error fetching actions from ${backend.NAME}: ${error.message}`);
return { backend, actions: {} };
}
});
const backendActions = await Promise.all(fetchPromises);
const choices = [];
// Reverted to group choices by backend for the CLI prompt
for (const { backend, actions } of backendActions) {
const actionNames = Object.keys(actions);
if (actionNames.length > 0) {
choices.push({ name: `\n=== ${backend.NAME} ===\n`, disabled: true });
for (const actionName of actionNames.sort()) {
const definition = actions[actionName];
choices.push({
name: ` ${definition.title || _.startCase(actionName)}`,
value: { backend, action: actionName, definition },
checked: true,
});
}
}
}
if (choices.length === 0) {
console.log("No actions found to synchronize.");
return;
}
const selectedActions = await selectActions(choices, "Select actions to generate:");
if (!selectedActions || selectedActions.length === 0) {
console.log("No actions selected. Exiting.");
return;
}
console.log("\n⚙️ Generating actions...");
const progressBar = new cliProgress.SingleBar({}, cliProgress.Presets.shades_classic);
progressBar.start(selectedActions.length, 0);
const allGeneratedFiles = [];
for (const item of selectedActions) {
try {
const result = await generateActionFile(item.backend, item.action, item.definition);
allGeneratedFiles.push(result);
}
catch (error) {
progressBar.stop();
console.error(`\n❌ Error generating action ${item.action}: ${error.message}`);
}
progressBar.increment();
}
progressBar.stop();
await generateAppLevelIndexFiles(allGeneratedFiles, backendConfigs);
await generateActionRegistry(allGeneratedFiles, backendConfigs);
console.log(`\n✨ Generated ${allGeneratedFiles.length} actions successfully.`);
}
/**
* CLI entry point.
*/
export async function generateActions() {
try {
console.log("🚀 Starting action synchronization...");
await main();
console.log("\n✅ Action synchronization completed!");
}
catch (error) {
console.error("\n❌ Action synchronization failed:", error.message);
if (process.env.DEBUG) {
console.error("Stack trace:", error.stack);
}
process.exit(1);
}
}