UNPKG

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