UNPKG

gensx

Version:
1,026 lines 58.4 kB
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unsafe-assignment */ /* eslint-disable @typescript-eslint/no-unsafe-call */ /* eslint-disable @typescript-eslint/no-unsafe-member-access */ import { serve } from "@hono/node-server"; import { Ajv } from "ajv/dist/ajv.js"; import { Hono } from "hono"; import { cors } from "hono/cors"; import { ulid } from "ulidx"; /** * Custom error classes for consistent error handling */ export class NotFoundError extends Error { statusCode = 404; constructor(message) { super(message); this.name = "NotFoundError"; } } export class BadRequestError extends Error { statusCode = 400; constructor(message) { super(message); this.name = "BadRequestError"; } } export class ServerError extends Error { statusCode = 500; constructor(message) { super(message); this.name = "ServerError"; } } /** * GenSX Server - A development server for GenSX workflows */ export class GensxServer { app; port; hostname; workflowMap; schemaMap; executionsMap; isRunning = false; server = null; ajv; logger; /** * Create a new GenSX dev server */ constructor(workflows = {}, options = { logger: { info: (msg, ...args) => { console.info(msg, ...args); }, error: (msg, err) => { console.error(msg, err); }, warn: (msg) => { console.warn(msg); }, }, }, schemas = {}) { this.port = options.port ?? 1337; this.hostname = options.hostname ?? "localhost"; this.app = new Hono(); this.workflowMap = new Map(); this.schemaMap = new Map(Object.entries(schemas)); this.executionsMap = new Map(); this.ajv = new Ajv(); this.logger = options.logger; // Register all workflows from the input this.registerWorkflows(workflows); // Set up error handling middleware this.setupErrorHandler(); // Set up routes this.setupRoutes(); } /** * Set up error handling middleware */ setupErrorHandler() { this.app.onError((err, c) => { this.logger.error("❌ Server error:", err.message); // Handle different types of errors if (err instanceof NotFoundError) { return c.json({ error: err.message }, 404); } else if (err instanceof BadRequestError) { return c.json({ error: err.message }, 400); } else { const message = err instanceof Error ? err.message : String(err); return c.json({ error: "Internal server error", message }, 500); } }); } /** * Register workflows with the server */ registerWorkflows(workflows) { for (const [exportName, workflow] of Object.entries(workflows)) { // GenSX Workflows are functions with a __gensxWorkflow property (created by gensx.Workflow()) if (typeof workflow === "function") { // Handle GenSX workflow functions const workflowFn = workflow; // Check if this is a GenSX workflow function if (workflowFn.__gensxWorkflow === true) { const workflowName = workflowFn.name ?? exportName; // Wrap the function to match the expected interface const wrappedWorkflow = { name: workflowName, run: workflowFn, }; this.workflowMap.set(workflowName, wrappedWorkflow); } } } if (this.workflowMap.size === 0) { this.logger.warn("⚠️ No valid workflows were registered!"); } } /** * Get a workflow by name or throw NotFoundError */ getWorkflowOrThrow(workflowName) { const workflow = this.workflowMap.get(workflowName); if (!workflow) { throw new NotFoundError(`Workflow '${workflowName}' not found`); } return workflow; } /** * Get an execution by ID or throw NotFoundError */ getExecutionOrThrow(executionId, workflowName) { this.getWorkflowOrThrow(workflowName); const execution = this.executionsMap.get(executionId); if (!execution) { throw new NotFoundError(`Execution '${executionId}' not found`); } if (workflowName && execution.workflowName !== workflowName) { throw new NotFoundError(`Execution '${executionId}' does not belong to workflow '${workflowName}'`); } return execution; } /** * Parse request body with error handling */ async parseJsonBody(c) { try { return await c.req.json(); } catch (_) { throw new BadRequestError("Invalid JSON"); } } /** * Validate input against schema * Throws BadRequestError if validation fails */ validateInput(workflowName, input) { // Check if input is missing if (input === undefined) { throw new BadRequestError("Missing required input parameters"); } // Get schema for this workflow const schema = this.schemaMap.get(workflowName); if (!schema?.input) { // If no schema, we can't validate return; } // Use Ajv to validate the input against the schema const validate = this.ajv.compile(schema.input); const valid = validate(input); if (!valid) { const errors = validate.errors ?? []; const errorMessages = errors .map((err) => `${err.instancePath} ${err.message}`) .join("; "); throw new BadRequestError(`Input validation failed: the input${errorMessages}`); } } /** * Set up server routes */ setupRoutes() { // Add middleware this.app.use("*", async (c, next) => { const start = Date.now(); const { method, url } = c.req; this.logger.info(`<-- ${method} ${url}`); await next(); const duration = Date.now() - start; this.logger.info(`--> ${method} ${url} ${c.res.status} ${duration}ms`); }); this.app.use("*", cors()); // List all workflows this.app.get(`/workflows`, (c) => { return c.json({ workflows: this.getWorkflows(), }); }); // Get a single workflow by name this.app.get(`/workflows/:workflowName`, (c) => { const workflowName = c.req.param("workflowName"); this.getWorkflowOrThrow(workflowName); // Get schema info const schema = this.schemaMap.get(workflowName); const id = generateWorkflowId(workflowName); const now = new Date().toISOString(); return c.json({ id, name: workflowName, inputSchema: schema?.input ?? { type: "object", properties: {} }, outputSchema: schema?.output ?? { type: "object", properties: {} }, createdAt: now, updatedAt: now, url: `http://${this.hostname}:${this.port}/workflows/${workflowName}`, }); }); // Start workflow execution asynchronously this.app.post(`/workflows/:workflowName/start`, async (c) => { const workflowName = c.req.param("workflowName"); // Will throw NotFoundError if workflow doesn't exist const workflow = this.getWorkflowOrThrow(workflowName); try { // Get request body for workflow parameters const body = await this.parseJsonBody(c); // Validate that input exists and matches schema this.validateInput(workflowName, body); // Only create execution ID after validation succeeds const executionId = generateExecutionId(); const now = new Date().toISOString(); // Initialize execution record const execution = { id: executionId, workflowName, executionStatus: "queued", createdAt: now, input: body, workflowMessages: [], }; // Store the execution this.executionsMap.set(executionId, execution); // Execute the workflow asynchronously void this.executeWorkflowAsync(workflowName, workflow, executionId, body); // Return immediately with executionId return c.json({ executionId, executionStatus: "queued", }, 202); } catch (error) { if (error instanceof BadRequestError) { this.logger.error(`❌ Validation error in workflow '${workflowName}':`, error.message); return c.json({ error: error.message, }, 400); } this.logger.error(`❌ Error starting workflow '${workflowName}':`, error); return c.json({ error: error instanceof Error ? error.message : String(error), }, 500); } }); // Get execution status this.app.get(`/workflows/:workflowName/executions/:executionId`, (c) => { const workflowName = c.req.param("workflowName"); const executionId = c.req.param("executionId"); // Will throw NotFoundError if execution doesn't exist or doesn't match workflow const execution = this.getExecutionOrThrow(executionId, workflowName); // Construct the response data with proper type safety const responseData = { id: execution.id, executionStatus: execution.executionStatus, createdAt: execution.createdAt, }; // Add optional fields if they exist if (execution.finishedAt) { responseData.finishedAt = execution.finishedAt; } if (execution.output !== undefined) { responseData.output = execution.output; } if (execution.error) { responseData.logs = { stderr: execution.error, }; } return c.json(responseData); }); // Get execution progress events this.app.get(`/workflowExecutions/:executionId/progress`, (c) => { const executionId = c.req.param("executionId"); const execution = this.executionsMap.get(executionId); if (!execution) { throw new NotFoundError(`Execution '${executionId}' not found`); } // Get the last event ID from query param or header const lastEventId = c.req.query("lastEventId") ?? c.req.header("Last-Event-Id"); // Filter events based on lastEventId if provided const events = execution.workflowMessages; const filteredEvents = lastEventId ? events.filter((event) => event.id > lastEventId) : events; // Check if we should use SSE format const acceptHeader = c.req.header("Accept"); const useSSE = acceptHeader === "text/event-stream"; if (useSSE) { // Create a stream for SSE events const stream = new ReadableStream({ start: (controller) => { for (const event of filteredEvents) { const eventData = JSON.stringify(event); controller.enqueue(new TextEncoder().encode(`id: ${event.id}\ndata: ${eventData}\n\n`)); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); } else { // Return NDJSON format const stream = new ReadableStream({ start: (controller) => { for (const event of filteredEvents) { controller.enqueue(new TextEncoder().encode(JSON.stringify(event) + "\n")); } controller.close(); }, }); return new Response(stream, { headers: { "Content-Type": "application/x-ndjson", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); } }); // List executions for a workflow this.app.get(`/workflows/:workflowName/executions`, (c) => { const workflowName = c.req.param("workflowName"); // Will throw NotFoundError if workflow doesn't exist this.getWorkflowOrThrow(workflowName); // Filter executions for this workflow const executions = Array.from(this.executionsMap.values()) .filter((exec) => exec.workflowName === workflowName) // Sort by createdAt in descending order (newest first) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) .map((exec) => ({ id: exec.id, executionStatus: exec.executionStatus, createdAt: exec.createdAt, finishedAt: exec.finishedAt, })); return c.json({ executions, }); }); // Execute workflow endpoint this.app.post(`/workflows/:workflowName`, async (c) => { const workflowName = c.req.param("workflowName"); // Will throw NotFoundError if workflow doesn't exist const workflow = this.getWorkflowOrThrow(workflowName); let body = {}; try { // Get request body for workflow parameters body = await this.parseJsonBody(c); // Validate that input exists and matches schema this.validateInput(workflowName, body); // Only create execution ID after validation succeeds const executionId = generateExecutionId(); const now = new Date().toISOString(); this.logger.info(`⚡️ Executing workflow '${workflowName}' with params:`, body); // Initialize execution record const execution = { id: executionId, workflowName, executionStatus: "running", // Start directly in running state since this is synchronous createdAt: now, input: body, workflowMessages: [], }; // Store the execution this.executionsMap.set(executionId, execution); // Execute the workflow const runMethod = workflow.run; if (typeof runMethod !== "function") { // Update execution with error const errorMessage = `Workflow '${workflowName}' doesn't have a run method`; execution.executionStatus = "failed"; execution.error = errorMessage; execution.finishedAt = new Date().toISOString(); this.executionsMap.set(executionId, execution); throw new ServerError(errorMessage); } try { // Check if we should stream progress events const acceptHeader = c.req.header("Accept"); const shouldStreamProgress = acceptHeader === "text/event-stream" || acceptHeader === "application/x-ndjson"; if (shouldStreamProgress) { // Create a stream for progress events const stream = new ReadableStream({ start: async (controller) => { try { // Set up progress listener const messageListener = (event) => { const message = { ...event, id: Date.now().toString(), timestamp: new Date().toISOString(), }; const messageData = JSON.stringify(message); execution.workflowMessages.push(message); if (acceptHeader === "text/event-stream") { controller.enqueue(new TextEncoder().encode(`id: ${message.id}\ndata: ${messageData}\n\n`)); } else { controller.enqueue(new TextEncoder().encode(messageData + "\n")); } }; // Execute workflow with progress listener const result = await runMethod.call(workflow, body, { messageListener, }); if (result && typeof result === "object" && Symbol.asyncIterator in result) { for await (const chunk of result) { const outputEvent = { id: Date.now().toString(), timestamp: new Date().toISOString(), type: "output", content: typeof chunk === "string" ? chunk : JSON.stringify(chunk), }; if (acceptHeader === "text/event-stream") { controller.enqueue(new TextEncoder().encode(`id: ${outputEvent.id}\ndata: ${JSON.stringify(outputEvent)}\n\n`)); } else { controller.enqueue(new TextEncoder().encode(JSON.stringify(outputEvent) + "\n")); } } } else { const outputEvent = { id: Date.now().toString(), timestamp: new Date().toISOString(), type: "output", content: typeof result === "string" ? result : JSON.stringify(result), }; if (acceptHeader === "text/event-stream") { controller.enqueue(new TextEncoder().encode(`id: ${outputEvent.id}\ndata: ${JSON.stringify(outputEvent)}\n\n`)); } else { controller.enqueue(new TextEncoder().encode(JSON.stringify(outputEvent) + "\n")); } } // Update execution with result execution.executionStatus = "completed"; execution.output = result; execution.finishedAt = new Date().toISOString(); this.executionsMap.set(executionId, execution); controller.close(); } catch (error) { // Update execution with error execution.executionStatus = "failed"; execution.error = error instanceof Error ? error.message : String(error); execution.finishedAt = new Date().toISOString(); this.executionsMap.set(executionId, execution); // Send error event const errorEvent = { id: Date.now().toString(), timestamp: new Date().toISOString(), type: "error", executionId, executionStatus: "failed", error: error instanceof Error ? error.message : String(error), }; const errorEventData = JSON.stringify(errorEvent); if (acceptHeader === "text/event-stream") { controller.enqueue(new TextEncoder().encode(`id: ${errorEvent.id}\ndata: ${errorEventData}\n\n`)); } else { controller.enqueue(new TextEncoder().encode(errorEventData + "\n")); } controller.close(); } }, }); return new Response(stream, { headers: { "Content-Type": acceptHeader === "text/event-stream" ? "text/event-stream" : "application/x-ndjson", "Cache-Control": "no-cache", Connection: "keep-alive", }, }); } // Handle regular non-streaming execution const result = await runMethod.call(workflow, body, { messageListener: (event) => { const message = { ...event, id: Date.now().toString(), timestamp: new Date().toISOString(), }; execution.workflowMessages.push(message); }, }); // Update execution with result execution.executionStatus = "completed"; execution.output = result; execution.finishedAt = new Date().toISOString(); this.executionsMap.set(executionId, execution); // Handle different response types if (result && typeof result === "object" && Symbol.asyncIterator in result) { // Handle streaming responses return this.handleStreamingResponse(c, result); } // Handle regular JSON response return c.json({ executionId, executionStatus: "completed", output: result, }); } catch (error) { this.logger.error(`❌ Error executing workflow '${workflowName}':`, error instanceof Error ? error.message : String(error)); execution.executionStatus = "failed"; execution.error = error instanceof Error ? error.message : String(error); execution.finishedAt = new Date().toISOString(); this.executionsMap.set(executionId, execution); return c.json({ executionId, executionStatus: "failed", error: error instanceof Error ? error.message : String(error), }, 422); } } catch (error) { // For validation errors, don't create an execution record if (error instanceof BadRequestError) { this.logger.error(`❌ Validation error in workflow '${workflowName}':`, error.message); return c.json({ error: error.message, }, 400); } this.logger.error(`❌ Error executing workflow '${workflowName}':`, error instanceof Error ? error.message : String(error)); // For other errors, proceed with server error return c.json({ error: error instanceof Error ? error.message : String(error), }, 500); } }); // UI for testing workflows this.app.get("/openapi.json", (c) => { return c.json(this.generateOpenApiSpec()); }); this.app.get("/swagger-ui", (c) => { const html = this.generateSwaggerUI(); return c.html(html); }); } /** * Handle streaming responses */ handleStreamingResponse(_c, streamResult) { const logger = this.logger; const stream = new ReadableStream({ async start(controller) { try { for await (const chunk of streamResult) { // Stringify the chunk if it's not already a string const chunkStr = typeof chunk === "string" ? chunk : JSON.stringify(chunk) + "\n"; controller.enqueue(new TextEncoder().encode(chunkStr)); } // Close the stream controller.close(); } catch (error) { logger.error(`❌ Error in streaming response:`, error instanceof Error ? error.message : String(error)); controller.error(error); } }, }); return new Response(stream, { headers: { "Content-Type": "application/stream", "Transfer-Encoding": "chunked", }, }); } /** * Generate OpenAPI specification dynamically based on server configuration */ generateOpenApiSpec() { const workflows = this.getWorkflows(); return { openapi: "3.0.0", info: { title: "GenSX API", version: "1.0.0", description: "API documentation for GenSX workflows", }, servers: [ { url: `http://${this.hostname}:${this.port}`, description: "Development Server", }, ], tags: [ { name: "Workflows", description: "List and manage workflows", }, ...workflows.map((workflow) => ({ name: workflow.name, description: `Operations for ${workflow.name} workflow`, })), ], paths: { "/workflows": { get: { tags: ["Workflows"], summary: "List all workflows", responses: { "200": { description: "List of available workflows", content: { "application/json": { example: { workflows, }, }, }, }, }, }, }, "/workflowExecutions/{executionId}/progress": { get: { tags: ["Workflows"], summary: "Get progress events for a workflow execution", parameters: [ { name: "executionId", in: "path", required: true, schema: { type: "string" }, description: "ID of the workflow execution", }, { name: "lastEventId", in: "query", required: false, schema: { type: "string" }, description: "Filter events after this ID", }, ], responses: { "200": { description: "Progress events in SSE or NDJSON format", content: { "text/event-stream": { schema: { type: "string", example: 'id: 1\ndata: {"type":"start","workflowName":"testWorkflow"}\n\n', }, }, "application/x-ndjson": { schema: { type: "string", example: '{"id":"1","type":"start","workflowName":"testWorkflow"}\n', }, }, }, headers: { "Last-Event-Id": { schema: { type: "string", }, description: "ID of the last event sent", }, }, }, "404": { description: "Execution not found", content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, }, }, }, }, }, }, }, }, ...Object.fromEntries(workflows.map((workflow) => [ `/workflows/${workflow.name}`, { get: { tags: [workflow.name], summary: `Get ${workflow.name} workflow details`, responses: { "200": { description: "Workflow details", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, name: { type: "string" }, inputSchema: { type: "object" }, outputSchema: { type: "object" }, createdAt: { type: "string", format: "date-time", }, updatedAt: { type: "string", format: "date-time", }, url: { type: "string" }, }, }, }, }, }, }, }, post: { tags: [workflow.name], summary: `Execute ${workflow.name} workflow`, requestBody: { required: true, content: { "application/json": { schema: workflow.inputSchema ?? { type: "object", properties: {}, }, }, }, }, responses: { "200": { description: "Successful execution", content: { "application/json": { schema: { type: "object", properties: { executionId: { type: "string" }, executionStatus: { type: "string", enum: [ "completed", "queued", "running", "failed", ], }, output: workflow.outputSchema ?? { type: "object", properties: {}, }, }, }, }, "text/event-stream": { schema: { type: "string", description: "Server-Sent Events (SSE) stream of progress events", example: 'id: 1\ndata: {"type":"start","workflowName":"testWorkflow"}\n\nid: 2\ndata: {"type":"progress","data":"Processing..."}\n\n', }, }, "application/x-ndjson": { schema: { type: "string", description: "Newline-delimited JSON stream of progress events", example: '{"id":"1","type":"start","workflowName":"testWorkflow"}\n{"id":"2","type":"progress","data":"Processing..."}\n', }, }, "application/stream": { schema: { type: "string", description: "Streaming response for workflows that returns streaming output", }, }, }, headers: { "Content-Type": { schema: { type: "string", enum: [ "application/json", "text/event-stream", "application/x-ndjson", "application/stream", ], }, description: "Response format based on Accept header", }, "Transfer-Encoding": { schema: { type: "string", enum: ["chunked"], }, description: "Present for streaming responses", }, }, }, "400": { description: "Bad request", content: { "application/json": { schema: { type: "object", properties: { error: { type: "string" }, }, }, }, }, }, "422": { description: "Workflow execution failed", content: { "application/json": { schema: { type: "object", properties: { executionId: { type: "string" }, executionStatus: { type: "string", enum: ["failed"], }, error: { type: "string" }, }, }, }, }, }, }, }, }, ])), ...Object.fromEntries(workflows.map((workflow) => [ `/workflows/${workflow.name}/start`, { post: { tags: [workflow.name], summary: `Start ${workflow.name} workflow asynchronously`, requestBody: { required: true, content: { "application/json": { schema: workflow.inputSchema ?? { type: "object", properties: {}, }, }, }, }, responses: { "202": { description: "Workflow started", content: { "application/json": { schema: { type: "object", properties: { executionId: { type: "string" }, executionStatus: { type: "string", enum: ["queued"], }, }, }, }, }, }, }, }, }, ])), ...Object.fromEntries(workflows.map((workflow) => [ `/workflows/${workflow.name}/executions/{executionId}`, { get: { tags: [workflow.name], summary: `Get execution status for ${workflow.name} workflow`, parameters: [ { name: "executionId", in: "path", required: true, schema: { type: "string" }, }, ], responses: { "200": { description: "Execution status", content: { "application/json": { schema: { type: "object", properties: { id: { type: "string" }, executionStatus: { type: "string", enum: [ "queued", "starting", "running", "completed", "failed", ], }, createdAt: { type: "string", format: "date-time", }, finishedAt: { type: "string", format: "date-time", }, output: workflow.outputSchema ?? {}, error: { type: "string" }, }, }, }, }, }, }, }, }, ])), ...Object.fromEntries(workflows.map((workflow) => [ `/workflows/${workflow.name}/executions`, { get: { tags: [workflow.name], summary: `List executions for ${workflow.name} workflow`, responses: { "200": { description: "List of workflow executions", content: { "application/json": { schema: { type: "object", properties: { executions: { type: "array", items: { type: "object", properties: { id: { type: "string" }, executionStatus: { type: "string", enum: [ "queued", "starting", "running", "completed", "failed", ], }, createdAt: { type: "string", format: "date-time", }, finishedAt: { type: "string", format: "date-time", }, }, }, }, }, }, }, }, }, }, }, }, ])), }, }; } /** * Generate Swagger UI HTML */ generateSwaggerUI() { return ` <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>GenSX AP