mirror of
https://github.com/run-llama/workflows-ts.git
synced 2026-07-20 21:59:32 -04:00
feat: add Fastify-based workflow server (#192)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@llamaindex/workflow-server": patch
|
||||
---
|
||||
|
||||
Add a workflow server for serving workflows as REST APIs
|
||||
@@ -0,0 +1,104 @@
|
||||
# Workflow Server Demo
|
||||
|
||||
This demo shows how to use `@llamaindex/workflow-server` to expose LlamaIndex workflows as REST APIs with OpenAPI documentation.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Install dependencies:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
2. Start the server:
|
||||
|
||||
```bash
|
||||
pnpm start
|
||||
```
|
||||
|
||||
3. Open Swagger UI:
|
||||
|
||||
```
|
||||
http://localhost:3000/documentation
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Root
|
||||
|
||||
```bash
|
||||
curl http://localhost:3000/
|
||||
```
|
||||
|
||||
### Run Workflow and Stream Events
|
||||
|
||||
Run example Echo workflow that print out a message multiple times with streaming events:
|
||||
|
||||
```bash
|
||||
HANDLER_ID=$(curl -s -X POST http://localhost:3000/workflows/echo/run-nowait -H "Content-Type: application/json" -d '{"data":{"message":"Hello World","times":3,"delay":1000}}' | grep -o '"handlerId":"[^"]*"' | cut -d'"' -f4) && curl -N http://localhost:3000/events/$HANDLER_ID/stream?sse=true
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
data: {"type":"echoStart","data":{"message":"Hello World","times":3,"delay":1000},"qualified_name":"echoStart"}
|
||||
|
||||
data: {"type":"echo","data":"Hello World","qualified_name":"echo"}
|
||||
|
||||
data: {"type":"echo","data":"Hello World","qualified_name":"echo"}
|
||||
|
||||
data: {"type":"echo","data":"Hello World","qualified_name":"echo"}
|
||||
|
||||
data: {"type":"echoStop","data":"Echoed \"Hello World\" 3 time(s)","qualified_name":"echoStop"}
|
||||
|
||||
data: {"status":"completed","result":"Echoed \"Hello World\" 3 time(s)"}
|
||||
```
|
||||
|
||||
**Note:** The `times` parameter defaults to 1, and `delay` defaults to 1000ms (1 second) between each echo.
|
||||
|
||||
## Configuration
|
||||
|
||||
The server is configured with:
|
||||
|
||||
```typescript
|
||||
const server = new WorkflowServer({
|
||||
title: "Workflow Server Demo",
|
||||
description: "A demo server showcasing @llamaindex/workflow-server",
|
||||
version: "1.0.0",
|
||||
enableSwaggerUI: true,
|
||||
});
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `PORT` | `3000` | Server port |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Defining Events
|
||||
|
||||
```typescript
|
||||
const startEvent = workflowEvent<InputType>();
|
||||
const stopEvent = workflowEvent<OutputType>();
|
||||
```
|
||||
|
||||
### Creating Workflows
|
||||
|
||||
```typescript
|
||||
const workflow = createWorkflow();
|
||||
workflow.handle([startEvent], (context, event) => {
|
||||
// Process event.data
|
||||
return stopEvent.with(result);
|
||||
});
|
||||
```
|
||||
|
||||
### Registering with Server
|
||||
|
||||
```typescript
|
||||
server.registerWorkflow("name", {
|
||||
workflow,
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "demo-server",
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start": "tsx server.ts",
|
||||
"dev": "tsx watch server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/swagger": "^9.4.2",
|
||||
"@fastify/swagger-ui": "^5.2.3",
|
||||
"@llamaindex/workflow-core": "workspace:*",
|
||||
"@llamaindex/workflow-server": "workspace:*",
|
||||
"fastify": "^5.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.4",
|
||||
"tsx": "^4.20.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* WorkflowServer Demo
|
||||
*
|
||||
* This demo shows how to use @llamaindex/workflow-server to expose
|
||||
* workflows as REST APIs with OpenAPI documentation.
|
||||
*
|
||||
* Run: pnpm start
|
||||
* Then visit: http://localhost:3000/documentation for Swagger UI
|
||||
*/
|
||||
|
||||
import swagger from "@fastify/swagger";
|
||||
import swaggerUi from "@fastify/swagger-ui";
|
||||
import {
|
||||
createWorkflowServer,
|
||||
fastifyPlugin,
|
||||
} from "@llamaindex/workflow-server";
|
||||
import Fastify from "fastify";
|
||||
import {
|
||||
calcInputEvent,
|
||||
calcOutputEvent,
|
||||
calculatorWorkflow,
|
||||
echoStartEvent,
|
||||
echoStopEvent,
|
||||
echoWorkflow,
|
||||
greetingWorkflow,
|
||||
greetStartEvent,
|
||||
greetStopEvent,
|
||||
} from "./workflows";
|
||||
|
||||
// ============================================
|
||||
// Create and Configure Server
|
||||
// ============================================
|
||||
|
||||
const workflowServer = createWorkflowServer({
|
||||
greeting: {
|
||||
workflow: greetingWorkflow,
|
||||
startEvent: greetStartEvent,
|
||||
stopEvent: greetStopEvent,
|
||||
},
|
||||
calculator: {
|
||||
workflow: calculatorWorkflow,
|
||||
startEvent: calcInputEvent,
|
||||
stopEvent: calcOutputEvent,
|
||||
},
|
||||
echo: {
|
||||
workflow: echoWorkflow,
|
||||
startEvent: echoStartEvent,
|
||||
stopEvent: echoStopEvent,
|
||||
},
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Start Fastify Server
|
||||
// ============================================
|
||||
|
||||
const app = Fastify({
|
||||
logger: true,
|
||||
});
|
||||
|
||||
// Register OpenAPI/Swagger documentation
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: "Workflow Server Demo",
|
||||
description:
|
||||
"A demo server showcasing @llamaindex/workflow-server capabilities",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(swaggerUi, {
|
||||
routePrefix: "/documentation",
|
||||
});
|
||||
|
||||
// Register the workflow server plugin
|
||||
await app.register(fastifyPlugin(workflowServer));
|
||||
|
||||
// Add a custom root route
|
||||
app.get("/", async () => {
|
||||
return {
|
||||
message: "Workflow Server Demo",
|
||||
documentation: "/documentation",
|
||||
endpoints: {
|
||||
health: "GET /health",
|
||||
listWorkflows: "GET /workflows",
|
||||
runWorkflow: "POST /workflows/:name/run",
|
||||
},
|
||||
registeredWorkflows: workflowServer.names(),
|
||||
};
|
||||
});
|
||||
|
||||
// Start the server
|
||||
const PORT = Number(process.env.PORT) || 3000;
|
||||
|
||||
try {
|
||||
await app.listen({ port: PORT, host: "0.0.0.0" });
|
||||
console.log(`
|
||||
╔════════════════════════════════════════════════════════════╗
|
||||
║ Workflow Server Demo Started ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Server: http://localhost:${PORT} ║
|
||||
║ API UI: http://localhost:${PORT}/documentation ║
|
||||
╠════════════════════════════════════════════════════════════╣
|
||||
║ Registered Workflows: ║
|
||||
║ - greeting : Simple greeting workflow ║
|
||||
║ - calculator : Basic math operations ║
|
||||
║ - echo : Echo back input data ║
|
||||
╚════════════════════════════════════════════════════════════╝
|
||||
|
||||
Try accessing the Swagger UI at http://localhost:${PORT}/documentation to explore the API endpoints.
|
||||
`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"target": "ES2022"
|
||||
},
|
||||
"include": ["./*.ts", "./workflows/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Calculator Workflow
|
||||
*
|
||||
* A workflow that performs basic math operations.
|
||||
* Input: { a: number, b: number, op: string }
|
||||
* Output: { result: number }
|
||||
*/
|
||||
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
|
||||
|
||||
export interface CalculatorInput {
|
||||
a: number;
|
||||
b: number;
|
||||
op: string;
|
||||
}
|
||||
|
||||
export interface CalculatorOutput {
|
||||
result: number;
|
||||
}
|
||||
|
||||
export const calcInputEvent = workflowEvent<CalculatorInput>({
|
||||
debugLabel: "calcInput",
|
||||
});
|
||||
|
||||
export const calcOutputEvent = workflowEvent<CalculatorOutput>({
|
||||
debugLabel: "calcOutput",
|
||||
});
|
||||
|
||||
export const calculatorWorkflow = createWorkflow();
|
||||
calculatorWorkflow.handle([calcInputEvent], (_context, event) => {
|
||||
const { a, b, op } = event.data;
|
||||
let result: number;
|
||||
|
||||
switch (op) {
|
||||
case "add":
|
||||
result = a + b;
|
||||
break;
|
||||
case "subtract":
|
||||
result = a - b;
|
||||
break;
|
||||
case "multiply":
|
||||
result = a * b;
|
||||
break;
|
||||
case "divide":
|
||||
if (b === 0) {
|
||||
throw new Error("Division by zero");
|
||||
}
|
||||
result = a / b;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown operation: ${op}`);
|
||||
}
|
||||
|
||||
return calcOutputEvent.with({ result });
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Echo Workflow
|
||||
*
|
||||
* Echoes a message multiple times with optional delay between each echo.
|
||||
* Input: { message: string, times?: number, delay?: number }
|
||||
* - message: The message to echo
|
||||
* - times: Number of times to echo (default: 1)
|
||||
* - delay: Delay in milliseconds between each echo (default: 1000)
|
||||
*/
|
||||
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
|
||||
|
||||
export interface EchoInput {
|
||||
message: string;
|
||||
times?: number;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
export const echoStartEvent = workflowEvent<EchoInput>({
|
||||
debugLabel: "echoStart",
|
||||
});
|
||||
|
||||
export const echoEvent = workflowEvent<string>({
|
||||
debugLabel: "echo",
|
||||
});
|
||||
|
||||
export const echoStopEvent = workflowEvent<string>({
|
||||
debugLabel: "echoStop",
|
||||
});
|
||||
|
||||
export const echoWorkflow = createWorkflow();
|
||||
echoWorkflow.handle([echoStartEvent], async (context, event) => {
|
||||
const { message, times = 1, delay = 1000 } = event.data;
|
||||
|
||||
// Echo the message multiple times
|
||||
for (let i = 0; i < times; i++) {
|
||||
context.sendEvent(echoEvent.with(message));
|
||||
if (i < times - 1 && delay > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
// Send final stop event
|
||||
return echoStopEvent.with(`Echoed "${message}" ${times} time(s)`);
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Greeting Workflow
|
||||
*
|
||||
* A simple workflow that greets a user by name.
|
||||
* Input: string (name)
|
||||
* Output: string (greeting message)
|
||||
*/
|
||||
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
|
||||
|
||||
export const greetStartEvent = workflowEvent<string>({
|
||||
debugLabel: "greetStart",
|
||||
});
|
||||
|
||||
export const greetStopEvent = workflowEvent<string>({
|
||||
debugLabel: "greetStop",
|
||||
});
|
||||
|
||||
export const greetingWorkflow = createWorkflow();
|
||||
greetingWorkflow.handle([greetStartEvent], (_context, event) => {
|
||||
const name = event.data || "World";
|
||||
return greetStopEvent.with(`Hello, ${name}! Welcome to the Workflow Server.`);
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
export {
|
||||
type CalculatorInput,
|
||||
type CalculatorOutput,
|
||||
calcInputEvent,
|
||||
calcOutputEvent,
|
||||
calculatorWorkflow,
|
||||
} from "./calculator";
|
||||
export {
|
||||
type EchoInput,
|
||||
echoEvent,
|
||||
echoStartEvent,
|
||||
echoStopEvent,
|
||||
echoWorkflow,
|
||||
} from "./echo";
|
||||
export {
|
||||
greetingWorkflow,
|
||||
greetStartEvent,
|
||||
greetStopEvent,
|
||||
} from "./greeting";
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Workflow, workflowEvent } from "@llamaindex/workflow-core";
|
||||
import { type Workflow, workflowEvent } from "@llamaindex/workflow-core";
|
||||
|
||||
//#region define workflow events
|
||||
const startEvent = workflowEvent<string>({
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# @llamaindex/workflow-server
|
||||
|
||||
A Fastify-based workflow server with OpenAPI documentation for LlamaIndex Workflows.
|
||||
|
||||
## Overview
|
||||
|
||||
This package provides a production-ready HTTP server for exposing your workflows as REST APIs. It includes:
|
||||
|
||||
- **RESTful API** - Standard HTTP endpoints for workflow management and execution
|
||||
- **OpenAPI/Swagger** - Automatic API documentation with Swagger UI
|
||||
- **Type Safety** - Full TypeScript support with proper typing
|
||||
- **Configurable** - Flexible options for customization
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @llamaindex/workflow-server fastify
|
||||
# or
|
||||
pnpm add @llamaindex/workflow-server fastify
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```typescript
|
||||
import Fastify from "fastify";
|
||||
import { createWorkflowServer, fastifyPlugin } from "@llamaindex/workflow-server";
|
||||
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
|
||||
|
||||
// Define events
|
||||
const startEvent = workflowEvent<string>();
|
||||
const stopEvent = workflowEvent<string>();
|
||||
|
||||
// Create workflow
|
||||
const greetingWorkflow = createWorkflow();
|
||||
greetingWorkflow.handle([startEvent], (_context, event) => {
|
||||
return stopEvent.with(`Hello, ${event.data}!`);
|
||||
});
|
||||
|
||||
// Create server with initial workflows
|
||||
const workflowServer = createWorkflowServer({
|
||||
greeting: {
|
||||
workflow: greetingWorkflow,
|
||||
startEvent,
|
||||
stopEvent,
|
||||
},
|
||||
});
|
||||
|
||||
// Start Fastify
|
||||
const app = Fastify({ logger: true });
|
||||
await app.register(fastifyPlugin(workflowServer));
|
||||
await app.listen({ port: 3000 });
|
||||
```
|
||||
|
||||
## With OpenAPI Documentation
|
||||
|
||||
To add Swagger UI documentation, install the swagger plugins and register them before the workflow server:
|
||||
|
||||
```bash
|
||||
npm install @fastify/swagger @fastify/swagger-ui
|
||||
```
|
||||
|
||||
```typescript
|
||||
import Fastify from "fastify";
|
||||
import swagger from "@fastify/swagger";
|
||||
import swaggerUi from "@fastify/swagger-ui";
|
||||
import { createWorkflowServer, fastifyPlugin } from "@llamaindex/workflow-server";
|
||||
|
||||
const workflowServer = createWorkflowServer({
|
||||
// ... workflows
|
||||
});
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
|
||||
// Register Swagger BEFORE the workflow server
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: {
|
||||
title: "My Workflow API",
|
||||
version: "1.0.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.register(swaggerUi, {
|
||||
routePrefix: "/documentation",
|
||||
});
|
||||
|
||||
// Then register the workflow server
|
||||
await app.register(fastifyPlugin(workflowServer));
|
||||
await app.listen({ port: 3000 });
|
||||
```
|
||||
|
||||
Swagger UI will be available at `http://localhost:3000/documentation`.
|
||||
|
||||
## Registering Workflows
|
||||
|
||||
### Using createWorkflowServer Factory (Recommended)
|
||||
|
||||
```typescript
|
||||
const workflowServer = createWorkflowServer({
|
||||
greeting: {
|
||||
workflow: greetingWorkflow,
|
||||
startEvent: greetStartEvent,
|
||||
stopEvent: greetStopEvent,
|
||||
},
|
||||
calculator: {
|
||||
workflow: calculatorWorkflow,
|
||||
startEvent: calcStartEvent,
|
||||
stopEvent: calcStopEvent,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Using register Method
|
||||
|
||||
You can also use `register` to add workflows after creation:
|
||||
|
||||
```typescript
|
||||
const workflowServer = createWorkflowServer();
|
||||
|
||||
workflowServer
|
||||
.register("greeting", {
|
||||
workflow: greetingWorkflow,
|
||||
startEvent: greetStartEvent,
|
||||
stopEvent: greetStopEvent,
|
||||
})
|
||||
.register("calculator", {
|
||||
workflow: calculatorWorkflow,
|
||||
startEvent: calcStartEvent,
|
||||
stopEvent: calcStopEvent,
|
||||
});
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
The server handles errors gracefully:
|
||||
|
||||
- **404** - Workflow not found
|
||||
- **408** - Workflow timeout
|
||||
- **500** - Internal server error
|
||||
|
||||
```typescript
|
||||
// Workflow not found
|
||||
POST /workflows/unknown/run
|
||||
// Response: 404 { "error": "Workflow \"unknown\" not found" }
|
||||
|
||||
// Timeout
|
||||
POST /workflows/slow/run { "data": "test", "timeout": 100 }
|
||||
// Response: 408 { "error": "Workflow \"slow\" timed out after 100ms" }
|
||||
```
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"root": false,
|
||||
"extends": "//",
|
||||
"files": {
|
||||
"includes": ["src/**"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"name": "@llamaindex/workflow-server",
|
||||
"version": "0.0.1",
|
||||
"description": "Fastify-based workflow server with OpenAPI support",
|
||||
"type": "module",
|
||||
"main": "dist/index.cjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"module": "dist/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.cts",
|
||||
"default": "./dist/index.cjs"
|
||||
},
|
||||
"default": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bunchee",
|
||||
"dev": "bunchee --watch",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"nanoid": "^5.1.5",
|
||||
"zod": "^3.25.76",
|
||||
"zod-to-json-schema": "^3.25.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@llamaindex/workflow-core": "workspace:*",
|
||||
"@types/node": "^24.0.4",
|
||||
"bunchee": "^6.5.4",
|
||||
"fastify": "^5.6.2",
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@llamaindex/workflow-core": "workspace:*",
|
||||
"fastify": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"fastify": {
|
||||
"optional": false
|
||||
}
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/run-llama/workflows-ts.git",
|
||||
"directory": "packages/server"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export class WorkflowNotFoundError extends Error {
|
||||
constructor(name: string) {
|
||||
super(`Workflow "${name}" not found`);
|
||||
this.name = "WorkflowNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkflowTimeoutError extends Error {
|
||||
constructor(name: string, timeout: number) {
|
||||
super(`Workflow "${name}" timed out after ${timeout}ms`);
|
||||
this.name = "WorkflowTimeoutError";
|
||||
}
|
||||
}
|
||||
|
||||
export class HandlerNotFoundError extends Error {
|
||||
constructor(handlerId: string) {
|
||||
super(`Handler "${handlerId}" not found`);
|
||||
this.name = "HandlerNotFoundError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import type { WorkflowContext } from "@llamaindex/workflow-core";
|
||||
import type { HandlerInfo, HandlerStatus, StreamEvent } from "./schemas";
|
||||
|
||||
/**
|
||||
* Simple mutex implementation using Promises.
|
||||
* Only one consumer can hold the lock at a time.
|
||||
*/
|
||||
class Mutex {
|
||||
private _locked = false;
|
||||
private _waiting: Array<() => void> = [];
|
||||
|
||||
async acquire(timeout?: number): Promise<boolean> {
|
||||
if (!this._locked) {
|
||||
this._locked = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const acquire = () => {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
this._locked = true;
|
||||
resolve(true);
|
||||
};
|
||||
|
||||
this._waiting.push(acquire);
|
||||
|
||||
if (timeout !== undefined && timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
const index = this._waiting.indexOf(acquire);
|
||||
if (index !== -1) {
|
||||
this._waiting.splice(index, 1);
|
||||
}
|
||||
resolve(false);
|
||||
}, timeout * 1000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
release(): void {
|
||||
if (this._waiting.length > 0) {
|
||||
const next = this._waiting.shift();
|
||||
if (next) {
|
||||
next();
|
||||
}
|
||||
} else {
|
||||
this._locked = false;
|
||||
}
|
||||
}
|
||||
|
||||
get isLocked(): boolean {
|
||||
return this._locked;
|
||||
}
|
||||
}
|
||||
|
||||
export interface HandlerContext {
|
||||
info: HandlerInfo;
|
||||
context: WorkflowContext;
|
||||
sendEvent: WorkflowContext["sendEvent"];
|
||||
/** Abort controller for cancelling the workflow */
|
||||
abortController: AbortController;
|
||||
/** Event queue for streaming */
|
||||
eventQueue: StreamEvent[];
|
||||
/** Mutex for exclusive stream consumer access */
|
||||
consumerMutex: Mutex;
|
||||
}
|
||||
|
||||
export class HandlerStore {
|
||||
private handlers: Map<string, HandlerContext> = new Map();
|
||||
|
||||
create(
|
||||
handlerId: string,
|
||||
workflowName: string,
|
||||
context: WorkflowContext,
|
||||
sendEvent: WorkflowContext["sendEvent"],
|
||||
): HandlerInfo {
|
||||
const info: HandlerInfo = {
|
||||
handlerId,
|
||||
workflowName,
|
||||
status: "running",
|
||||
startedAt: new Date(),
|
||||
};
|
||||
|
||||
const abortController = new AbortController();
|
||||
const eventQueue: StreamEvent[] = [];
|
||||
const consumerMutex = new Mutex();
|
||||
|
||||
const handlerContext: HandlerContext = {
|
||||
info,
|
||||
context,
|
||||
sendEvent,
|
||||
abortController,
|
||||
eventQueue,
|
||||
consumerMutex,
|
||||
};
|
||||
|
||||
this.handlers.set(handlerId, handlerContext);
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push an event to the handler's queue for streaming.
|
||||
*/
|
||||
pushEvent(handlerId: string, event: StreamEvent): void {
|
||||
const handler = this.handlers.get(handlerId);
|
||||
if (handler) {
|
||||
handler.eventQueue.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
get(handlerId: string): HandlerContext | undefined {
|
||||
return this.handlers.get(handlerId);
|
||||
}
|
||||
|
||||
getInfo(handlerId: string): HandlerInfo | undefined {
|
||||
return this.handlers.get(handlerId)?.info;
|
||||
}
|
||||
|
||||
list(filters?: {
|
||||
status?: HandlerStatus | undefined;
|
||||
workflowName?: string | undefined;
|
||||
}): HandlerInfo[] {
|
||||
let result = Array.from(this.handlers.values()).map((h) => h.info);
|
||||
|
||||
if (filters?.status) {
|
||||
result = result.filter((h) => h.status === filters.status);
|
||||
}
|
||||
|
||||
if (filters?.workflowName) {
|
||||
result = result.filter((h) => h.workflowName === filters.workflowName);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
updateStatus(
|
||||
handlerId: string,
|
||||
status: HandlerStatus,
|
||||
data?: { result?: unknown; error?: string },
|
||||
): void {
|
||||
const handler = this.handlers.get(handlerId);
|
||||
if (handler) {
|
||||
// Don't overwrite cancelled status - it's a terminal state set by user action
|
||||
if (handler.info.status === "cancelled") {
|
||||
return;
|
||||
}
|
||||
|
||||
handler.info.status = status;
|
||||
if (
|
||||
status === "completed" ||
|
||||
status === "error" ||
|
||||
status === "cancelled"
|
||||
) {
|
||||
handler.info.completedAt = new Date();
|
||||
}
|
||||
if (data?.result !== undefined) {
|
||||
handler.info.result = data.result;
|
||||
}
|
||||
if (data?.error !== undefined) {
|
||||
handler.info.error = data.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a running handler.
|
||||
* @param handlerId The handler ID to cancel
|
||||
* @returns true if the handler was cancelled, false if not found or already completed
|
||||
*/
|
||||
cancel(handlerId: string): boolean {
|
||||
const handler = this.handlers.get(handlerId);
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (handler.info.status !== "running") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Abort the workflow
|
||||
handler.abortController.abort(new Error("Handler cancelled by user"));
|
||||
|
||||
// Update status
|
||||
this.updateStatus(handlerId, "cancelled");
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire the stream consumer lock for a handler.
|
||||
* Only one consumer can stream events at a time.
|
||||
* @param handlerId The handler ID
|
||||
* @param timeout Timeout in seconds to wait for the lock
|
||||
* @returns true if lock was acquired, false if timed out
|
||||
*/
|
||||
async acquireStreamLock(
|
||||
handlerId: string,
|
||||
timeout: number = 1,
|
||||
): Promise<boolean> {
|
||||
const handler = this.handlers.get(handlerId);
|
||||
if (!handler) {
|
||||
return false;
|
||||
}
|
||||
return handler.consumerMutex.acquire(timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Release the stream consumer lock for a handler.
|
||||
* @param handlerId The handler ID
|
||||
*/
|
||||
releaseStreamLock(handlerId: string): void {
|
||||
const handler = this.handlers.get(handlerId);
|
||||
if (handler) {
|
||||
handler.consumerMutex.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queued events for streaming.
|
||||
* @param handlerId The handler ID
|
||||
* @returns Array of events or undefined if handler not found
|
||||
*/
|
||||
getQueuedEvents(handlerId: string): StreamEvent[] | undefined {
|
||||
const handler = this.handlers.get(handlerId);
|
||||
if (!handler) {
|
||||
return undefined;
|
||||
}
|
||||
// Drain the queue
|
||||
const events = [...handler.eventQueue];
|
||||
handler.eventQueue.length = 0;
|
||||
return events;
|
||||
}
|
||||
|
||||
delete(handlerId: string): boolean {
|
||||
const handler = this.handlers.get(handlerId);
|
||||
if (handler) {
|
||||
// Cleanup: abort if still running
|
||||
if (handler.info.status === "running") {
|
||||
handler.abortController.abort(new Error("Handler deleted"));
|
||||
}
|
||||
}
|
||||
return this.handlers.delete(handlerId);
|
||||
}
|
||||
}
|
||||
|
||||
export function createHandlerStore(): HandlerStore {
|
||||
return new HandlerStore();
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export {
|
||||
createWorkflowServer,
|
||||
fastifyPlugin,
|
||||
HandlerNotFoundError,
|
||||
WorkflowNotFoundError,
|
||||
WorkflowServer,
|
||||
WorkflowTimeoutError,
|
||||
type WorkflowsConfig,
|
||||
} from "./server";
|
||||
|
||||
export type {
|
||||
ExtractEventData,
|
||||
HandlerInfo,
|
||||
HandlerStatus,
|
||||
HealthResponse,
|
||||
RegisteredWorkflow,
|
||||
WorkflowConfig,
|
||||
WorkflowRunAsyncResponse,
|
||||
WorkflowRunRequest,
|
||||
WorkflowRunResponse,
|
||||
WorkflowServerOptions,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,239 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import type { HandlerStore } from "../handler-store";
|
||||
import {
|
||||
ErrorResponseSchema,
|
||||
HandlerIdParamsSchema,
|
||||
SendEventRequestSchema,
|
||||
SendEventResponseSchema,
|
||||
toJsonSchema,
|
||||
WorkflowEventsResponseSchema,
|
||||
WorkflowNameParamsSchema,
|
||||
WorkflowSchemaResponseSchema,
|
||||
} from "../schemas";
|
||||
import type {
|
||||
EventSchema,
|
||||
RegisteredWorkflow,
|
||||
WorkflowEventWithSchema,
|
||||
} from "../types";
|
||||
|
||||
interface EventRoutesContext {
|
||||
prefix: string;
|
||||
getWorkflow: (name: string) => RegisteredWorkflow | undefined;
|
||||
handlerStore: HandlerStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract JSON schema from a WorkflowEvent.
|
||||
*/
|
||||
function extractEventSchema(
|
||||
event: WorkflowEventWithSchema<unknown>,
|
||||
): EventSchema {
|
||||
const schema: EventSchema = {
|
||||
uniqueId: event.uniqueId,
|
||||
debugLabel: event.debugLabel,
|
||||
};
|
||||
|
||||
// If the event has a Zod schema attached (from zodEvent), convert it to JSON schema
|
||||
if ("schema" in event && event.schema != null) {
|
||||
try {
|
||||
// Try to use zod-to-json-schema for Zod schemas
|
||||
const zodSchema = event.schema;
|
||||
if (
|
||||
typeof zodSchema === "object" &&
|
||||
zodSchema !== null &&
|
||||
("_def" in zodSchema || "_zod" in zodSchema)
|
||||
) {
|
||||
try {
|
||||
schema.schema = zodToJsonSchema(zodSchema as z.ZodType, {
|
||||
$refStrategy: "none",
|
||||
});
|
||||
} catch {
|
||||
schema.schema = { type: "unknown", description: "Unknown schema" };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If schema extraction fails, just skip it
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events for a workflow
|
||||
*/
|
||||
function getWorkflowEvents(workflow: RegisteredWorkflow): EventSchema[] {
|
||||
const events: EventSchema[] = [];
|
||||
|
||||
events.push(extractEventSchema(workflow.startEvent));
|
||||
events.push(extractEventSchema(workflow.stopEvent));
|
||||
|
||||
if (workflow.additionalEvents) {
|
||||
for (const event of workflow.additionalEvents) {
|
||||
events.push(extractEventSchema(event));
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
function findEventByType(
|
||||
workflow: RegisteredWorkflow,
|
||||
eventType: string,
|
||||
): WorkflowEventWithSchema<unknown> | undefined {
|
||||
if (workflow.startEvent.uniqueId === eventType) {
|
||||
return workflow.startEvent;
|
||||
}
|
||||
if (workflow.stopEvent.uniqueId === eventType) {
|
||||
return workflow.stopEvent;
|
||||
}
|
||||
if (workflow.additionalEvents) {
|
||||
for (const event of workflow.additionalEvents) {
|
||||
if (event.uniqueId === eventType) {
|
||||
return event;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function registerEventRoutes(
|
||||
fastify: FastifyInstance,
|
||||
ctx: EventRoutesContext,
|
||||
): void {
|
||||
const { prefix, handlerStore } = ctx;
|
||||
|
||||
// GET /workflows/:name/schema - Get JSON schema for start and stop events
|
||||
fastify.get(`${prefix}/workflows/:name/schema`, {
|
||||
schema: {
|
||||
description: "Get JSON schema for start and stop events of a workflow",
|
||||
tags: ["Workflows"],
|
||||
params: toJsonSchema(WorkflowNameParamsSchema),
|
||||
response: {
|
||||
200: toJsonSchema(WorkflowSchemaResponseSchema),
|
||||
404: toJsonSchema(ErrorResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
request: FastifyRequest<{
|
||||
Params: z.infer<typeof WorkflowNameParamsSchema>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { name } = request.params;
|
||||
const workflow = ctx.getWorkflow(name);
|
||||
|
||||
if (!workflow) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: `Workflow "${name}" not found` });
|
||||
}
|
||||
|
||||
return {
|
||||
startEvent: extractEventSchema(workflow.startEvent),
|
||||
stopEvent: extractEventSchema(workflow.stopEvent),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// GET /workflows/:name/events - List all event schemas for a workflow
|
||||
fastify.get(`${prefix}/workflows/:name/events`, {
|
||||
schema: {
|
||||
description: "List all event schemas for a workflow",
|
||||
tags: ["Workflows"],
|
||||
params: toJsonSchema(WorkflowNameParamsSchema),
|
||||
response: {
|
||||
200: toJsonSchema(WorkflowEventsResponseSchema),
|
||||
404: toJsonSchema(ErrorResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
request: FastifyRequest<{
|
||||
Params: z.infer<typeof WorkflowNameParamsSchema>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { name } = request.params;
|
||||
const workflow = ctx.getWorkflow(name);
|
||||
|
||||
if (!workflow) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: `Workflow "${name}" not found` });
|
||||
}
|
||||
|
||||
return getWorkflowEvents(workflow);
|
||||
},
|
||||
});
|
||||
|
||||
// POST /events/:handlerId - Send event to a running workflow
|
||||
fastify.post(`${prefix}/events/:handlerId`, {
|
||||
schema: {
|
||||
description: "Send an event to a running workflow handler",
|
||||
tags: ["Events"],
|
||||
params: toJsonSchema(HandlerIdParamsSchema),
|
||||
body: toJsonSchema(SendEventRequestSchema),
|
||||
response: {
|
||||
200: toJsonSchema(SendEventResponseSchema),
|
||||
400: toJsonSchema(ErrorResponseSchema),
|
||||
404: toJsonSchema(ErrorResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
request: FastifyRequest<{
|
||||
Params: z.infer<typeof HandlerIdParamsSchema>;
|
||||
Body: z.infer<typeof SendEventRequestSchema>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { handlerId } = request.params;
|
||||
const { eventType, data } = request.body;
|
||||
|
||||
const handler = handlerStore.get(handlerId);
|
||||
if (!handler) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: `Handler "${handlerId}" not found` });
|
||||
}
|
||||
|
||||
// Check if handler is still running
|
||||
if (handler.info.status !== "running") {
|
||||
return reply.status(400).send({
|
||||
error: `Handler "${handlerId}" is not running (status: ${handler.info.status})`,
|
||||
});
|
||||
}
|
||||
|
||||
// Get the workflow to find the event type
|
||||
const workflow = ctx.getWorkflow(handler.info.workflowName);
|
||||
if (!workflow) {
|
||||
return reply.status(404).send({
|
||||
error: `Workflow "${handler.info.workflowName}" not found`,
|
||||
});
|
||||
}
|
||||
|
||||
const event = findEventByType(workflow, eventType);
|
||||
if (!event) {
|
||||
return reply.status(400).send({
|
||||
error: `Event type "${eventType}" not found in workflow "${handler.info.workflowName}"`,
|
||||
});
|
||||
}
|
||||
|
||||
// Create event data and send it
|
||||
try {
|
||||
const eventData = event.with(data);
|
||||
handler.sendEvent(eventData);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Event "${eventType}" sent to handler "${handlerId}"`,
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to send event";
|
||||
return reply.status(400).send({ error: message });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import type { HandlerStore } from "../handler-store";
|
||||
import {
|
||||
CancelQuerySchema,
|
||||
CancelResponseSchema,
|
||||
ErrorResponseSchema,
|
||||
HandlerIdParamsSchema,
|
||||
HandlerInfoSchema,
|
||||
HandlersQuerySchema,
|
||||
toJsonSchema,
|
||||
} from "../schemas";
|
||||
|
||||
interface HandlerRoutesContext {
|
||||
prefix: string;
|
||||
handlerStore: HandlerStore;
|
||||
}
|
||||
|
||||
export function registerHandlerRoutes(
|
||||
fastify: FastifyInstance,
|
||||
ctx: HandlerRoutesContext,
|
||||
): void {
|
||||
const { prefix, handlerStore } = ctx;
|
||||
|
||||
fastify.get(`${prefix}/handlers`, {
|
||||
schema: {
|
||||
description: "List all handlers with optional filters",
|
||||
tags: ["Handlers"],
|
||||
querystring: toJsonSchema(HandlersQuerySchema),
|
||||
response: {
|
||||
200: toJsonSchema(z.array(HandlerInfoSchema)),
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
request: FastifyRequest<{
|
||||
Querystring: z.infer<typeof HandlersQuerySchema>;
|
||||
}>,
|
||||
) => {
|
||||
const { status, workflow_name } = request.query;
|
||||
return handlerStore.list({ status, workflowName: workflow_name });
|
||||
},
|
||||
});
|
||||
|
||||
fastify.get(`${prefix}/handlers/:handlerId`, {
|
||||
schema: {
|
||||
description: "Get handler status and result",
|
||||
tags: ["Handlers"],
|
||||
params: toJsonSchema(HandlerIdParamsSchema),
|
||||
response: {
|
||||
200: toJsonSchema(HandlerInfoSchema),
|
||||
202: toJsonSchema(HandlerInfoSchema),
|
||||
404: toJsonSchema(ErrorResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
request: FastifyRequest<{
|
||||
Params: z.infer<typeof HandlerIdParamsSchema>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { handlerId } = request.params;
|
||||
const info = handlerStore.getInfo(handlerId);
|
||||
|
||||
if (!info) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: `Handler "${handlerId}" not found` });
|
||||
}
|
||||
|
||||
const statusCode = info.status === "running" ? 202 : 200;
|
||||
return reply.status(statusCode).send(info);
|
||||
},
|
||||
});
|
||||
|
||||
// POST /handlers/:handlerId/cancel - Cancel a running handler
|
||||
fastify.post(`${prefix}/handlers/:handlerId/cancel`, {
|
||||
schema: {
|
||||
description:
|
||||
"Cancel a running handler. Optionally purge the handler from memory.",
|
||||
tags: ["Handlers"],
|
||||
params: toJsonSchema(HandlerIdParamsSchema),
|
||||
querystring: toJsonSchema(CancelQuerySchema),
|
||||
response: {
|
||||
200: toJsonSchema(CancelResponseSchema),
|
||||
400: toJsonSchema(ErrorResponseSchema),
|
||||
404: toJsonSchema(ErrorResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
request: FastifyRequest<{
|
||||
Params: z.infer<typeof HandlerIdParamsSchema>;
|
||||
Querystring: Record<string, string | undefined>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { handlerId } = request.params;
|
||||
// Parse purge manually since Fastify doesn't apply Zod transforms
|
||||
const purge = request.query.purge === "true";
|
||||
|
||||
const info = handlerStore.getInfo(handlerId);
|
||||
if (!info) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: `Handler "${handlerId}" not found` });
|
||||
}
|
||||
|
||||
// Quick return if the handler is not running
|
||||
if (info.status !== "running") {
|
||||
return reply.status(400).send({
|
||||
error: `Handler "${handlerId}" is not running (status: ${info.status})`,
|
||||
});
|
||||
}
|
||||
|
||||
const cancelled = handlerStore.cancel(handlerId);
|
||||
if (!cancelled) {
|
||||
return reply.status(400).send({
|
||||
error: `Failed to cancel handler "${handlerId}"`,
|
||||
});
|
||||
}
|
||||
if (purge) {
|
||||
handlerStore.delete(handlerId);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: purge
|
||||
? `Handler "${handlerId}" cancelled and purged`
|
||||
: `Handler "${handlerId}" cancelled`,
|
||||
handler_id: handlerId,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { HealthResponseSchema, toJsonSchema } from "../schemas";
|
||||
|
||||
export function registerHealthRoutes(
|
||||
fastify: FastifyInstance,
|
||||
prefix: string,
|
||||
): void {
|
||||
fastify.get(`${prefix}/health`, {
|
||||
schema: {
|
||||
description: "Health check endpoint",
|
||||
tags: ["Health"],
|
||||
response: {
|
||||
200: toJsonSchema(HealthResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async () => ({ status: "ok" as const }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export { registerEventRoutes } from "./events";
|
||||
export { registerHandlerRoutes } from "./handlers";
|
||||
export { registerHealthRoutes } from "./health";
|
||||
export { registerStreamRoutes } from "./stream";
|
||||
export { registerWorkflowRoutes } from "./workflows";
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { StreamEvent } from "../types";
|
||||
import {
|
||||
createStreamGenerator,
|
||||
formatCompletionEvent,
|
||||
formatEvent,
|
||||
formatEventAsNDJSON,
|
||||
formatEventAsSSE,
|
||||
parseStreamQueryParams,
|
||||
type StreamGeneratorDeps,
|
||||
} from "./stream";
|
||||
|
||||
describe("parseStreamQueryParams", () => {
|
||||
it("should return default values when no query params provided", () => {
|
||||
const result = parseStreamQueryParams({});
|
||||
expect(result).toEqual({
|
||||
sse: true,
|
||||
acquireTimeout: 1,
|
||||
includeQualifiedName: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should parse sse=false correctly", () => {
|
||||
const result = parseStreamQueryParams({ sse: "false" });
|
||||
expect(result.sse).toBe(false);
|
||||
});
|
||||
|
||||
it("should parse sse=true correctly", () => {
|
||||
const result = parseStreamQueryParams({ sse: "true" });
|
||||
expect(result.sse).toBe(true);
|
||||
});
|
||||
|
||||
it("should parse acquire_timeout correctly", () => {
|
||||
const result = parseStreamQueryParams({ acquire_timeout: "5" });
|
||||
expect(result.acquireTimeout).toBe(5);
|
||||
});
|
||||
|
||||
it("should parse include_qualified_name=false correctly", () => {
|
||||
const result = parseStreamQueryParams({ include_qualified_name: "false" });
|
||||
expect(result.includeQualifiedName).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatEventAsSSE", () => {
|
||||
it("should format event as SSE", () => {
|
||||
const event: StreamEvent = {
|
||||
type: "TestEvent",
|
||||
data: { message: "hello" },
|
||||
qualified_name: "TestEvent",
|
||||
};
|
||||
const result = formatEventAsSSE(event);
|
||||
expect(result).toBe(`data: ${JSON.stringify(event)}\n\n`);
|
||||
});
|
||||
|
||||
it("should handle events with special characters", () => {
|
||||
const event: StreamEvent = {
|
||||
type: "TestEvent",
|
||||
data: { message: 'hello\nworld"test' },
|
||||
};
|
||||
const result = formatEventAsSSE(event);
|
||||
expect(result).toContain("data: ");
|
||||
expect(result.endsWith("\n\n")).toBe(true);
|
||||
// JSON.stringify handles the escaping
|
||||
expect(JSON.parse(result.slice(6, -2))).toEqual(event);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatEventAsNDJSON", () => {
|
||||
it("should format event as NDJSON", () => {
|
||||
const event: StreamEvent = {
|
||||
type: "TestEvent",
|
||||
data: { message: "hello" },
|
||||
};
|
||||
const result = formatEventAsNDJSON(event);
|
||||
expect(result).toBe(`${JSON.stringify(event)}\n`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatEvent", () => {
|
||||
const event: StreamEvent = {
|
||||
type: "TestEvent",
|
||||
data: { value: 42 },
|
||||
qualified_name: "com.example.TestEvent",
|
||||
};
|
||||
|
||||
it("should format as SSE when sse=true", () => {
|
||||
const result = formatEvent(event, {
|
||||
sse: true,
|
||||
includeQualifiedName: true,
|
||||
});
|
||||
expect(result).toContain("data: ");
|
||||
expect(result.endsWith("\n\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("should format as NDJSON when sse=false", () => {
|
||||
const result = formatEvent(event, {
|
||||
sse: false,
|
||||
includeQualifiedName: true,
|
||||
});
|
||||
expect(result).not.toContain("data: ");
|
||||
expect(result.endsWith("\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("should include qualified_name when includeQualifiedName=true", () => {
|
||||
const result = formatEvent(event, {
|
||||
sse: false,
|
||||
includeQualifiedName: true,
|
||||
});
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.qualified_name).toBe("com.example.TestEvent");
|
||||
});
|
||||
|
||||
it("should exclude qualified_name when includeQualifiedName=false", () => {
|
||||
const result = formatEvent(event, {
|
||||
sse: false,
|
||||
includeQualifiedName: false,
|
||||
});
|
||||
const parsed = JSON.parse(result);
|
||||
expect(parsed.qualified_name).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCompletionEvent", () => {
|
||||
it("should format completion event with status and result", () => {
|
||||
const result = formatCompletionEvent("completed", { value: 42 }, undefined);
|
||||
const parsed = JSON.parse(result.slice(6, -2)); // Remove "data: " and "\n\n"
|
||||
expect(parsed.type).toBe("__stream_complete__");
|
||||
expect(parsed.status).toBe("completed");
|
||||
expect(parsed.result).toEqual({ value: 42 });
|
||||
expect(parsed.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should format completion event with error", () => {
|
||||
const result = formatCompletionEvent(
|
||||
"error",
|
||||
undefined,
|
||||
"Something went wrong",
|
||||
);
|
||||
const parsed = JSON.parse(result.slice(6, -2));
|
||||
expect(parsed.type).toBe("__stream_complete__");
|
||||
expect(parsed.status).toBe("error");
|
||||
expect(parsed.error).toBe("Something went wrong");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createStreamGenerator", () => {
|
||||
function createMockDeps(
|
||||
overrides: Partial<StreamGeneratorDeps> = {},
|
||||
): StreamGeneratorDeps {
|
||||
return {
|
||||
getQueuedEvents: vi.fn(() => undefined),
|
||||
getHandlerStatus: vi.fn(() => undefined),
|
||||
getHandlerResult: vi.fn(() => undefined),
|
||||
releaseStreamLock: vi.fn(),
|
||||
pollInterval: 1, // Use minimal interval for tests
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
it("should yield formatted events from queue", async () => {
|
||||
const events: StreamEvent[] = [
|
||||
{ type: "Event1", data: { a: 1 } },
|
||||
{ type: "Event2", data: { b: 2 } },
|
||||
];
|
||||
|
||||
let callCount = 0;
|
||||
const deps = createMockDeps({
|
||||
getQueuedEvents: vi.fn(() => {
|
||||
callCount++;
|
||||
if (callCount === 1) return events;
|
||||
return undefined;
|
||||
}),
|
||||
getHandlerStatus: vi.fn(() => {
|
||||
// Return "running" once, then "completed"
|
||||
return callCount === 1 ? "running" : "completed";
|
||||
}),
|
||||
});
|
||||
|
||||
const generator = createStreamGenerator(
|
||||
"handler-1",
|
||||
{ sse: true, includeQualifiedName: true },
|
||||
deps,
|
||||
);
|
||||
|
||||
const results: string[] = [];
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk);
|
||||
}
|
||||
|
||||
expect(results.length).toBeGreaterThanOrEqual(2);
|
||||
expect(results[0]).toContain("Event1");
|
||||
expect(results[1]).toContain("Event2");
|
||||
});
|
||||
|
||||
it("should stop streaming when handler is no longer running", async () => {
|
||||
const deps = createMockDeps({
|
||||
getQueuedEvents: vi.fn(() => undefined),
|
||||
getHandlerStatus: vi.fn(() => "completed"),
|
||||
});
|
||||
|
||||
const generator = createStreamGenerator(
|
||||
"handler-1",
|
||||
{ sse: true, includeQualifiedName: true },
|
||||
deps,
|
||||
);
|
||||
|
||||
const results: string[] = [];
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk);
|
||||
}
|
||||
|
||||
// Should only have completion event
|
||||
expect(deps.getHandlerStatus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should release stream lock in finally block", async () => {
|
||||
const releaseLock = vi.fn();
|
||||
const deps = createMockDeps({
|
||||
getHandlerStatus: vi.fn(() => "completed"),
|
||||
releaseStreamLock: releaseLock,
|
||||
});
|
||||
|
||||
const generator = createStreamGenerator(
|
||||
"handler-1",
|
||||
{ sse: false, includeQualifiedName: true },
|
||||
deps,
|
||||
);
|
||||
|
||||
for await (const _ of generator) {
|
||||
// consume generator
|
||||
}
|
||||
|
||||
expect(releaseLock).toHaveBeenCalledWith("handler-1");
|
||||
});
|
||||
|
||||
it("should send completion event only for SSE format", async () => {
|
||||
const deps = createMockDeps({
|
||||
getHandlerStatus: vi.fn(() => "completed"),
|
||||
getHandlerResult: vi.fn(() => ({ result: "done", error: undefined })),
|
||||
});
|
||||
|
||||
// With SSE
|
||||
const sseGenerator = createStreamGenerator(
|
||||
"handler-1",
|
||||
{ sse: true, includeQualifiedName: true },
|
||||
deps,
|
||||
);
|
||||
|
||||
const sseResults: string[] = [];
|
||||
for await (const chunk of sseGenerator) {
|
||||
sseResults.push(chunk);
|
||||
}
|
||||
|
||||
expect(sseResults.some((r) => r.includes("__stream_complete__"))).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
// Without SSE
|
||||
const ndjsonGenerator = createStreamGenerator(
|
||||
"handler-2",
|
||||
{ sse: false, includeQualifiedName: true },
|
||||
deps,
|
||||
);
|
||||
|
||||
const ndjsonResults: string[] = [];
|
||||
for await (const chunk of ndjsonGenerator) {
|
||||
ndjsonResults.push(chunk);
|
||||
}
|
||||
|
||||
expect(ndjsonResults.some((r) => r.includes("__stream_complete__"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should use NDJSON format when sse=false", async () => {
|
||||
const events: StreamEvent[] = [{ type: "TestEvent", data: { x: 1 } }];
|
||||
|
||||
let callCount = 0;
|
||||
const deps = createMockDeps({
|
||||
getQueuedEvents: vi.fn(() => {
|
||||
callCount++;
|
||||
return callCount === 1 ? events : undefined;
|
||||
}),
|
||||
getHandlerStatus: vi.fn(() =>
|
||||
callCount === 1 ? "running" : "completed",
|
||||
),
|
||||
});
|
||||
|
||||
const generator = createStreamGenerator(
|
||||
"handler-1",
|
||||
{ sse: false, includeQualifiedName: true },
|
||||
deps,
|
||||
);
|
||||
|
||||
const results: string[] = [];
|
||||
for await (const chunk of generator) {
|
||||
results.push(chunk);
|
||||
}
|
||||
|
||||
// NDJSON format should not have "data: " prefix
|
||||
expect(results[0]).not.toContain("data:");
|
||||
expect(results[0]?.endsWith("\n")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import type { z } from "zod";
|
||||
import type { HandlerStore } from "../handler-store";
|
||||
import {
|
||||
ErrorResponseSchema,
|
||||
HandlerIdParamsSchema,
|
||||
StreamQuerySchema,
|
||||
toJsonSchema,
|
||||
} from "../schemas";
|
||||
import type { StreamEvent } from "../types";
|
||||
|
||||
interface StreamRoutesContext {
|
||||
prefix: string;
|
||||
handlerStore: HandlerStore;
|
||||
}
|
||||
|
||||
export interface StreamOptions {
|
||||
sse: boolean;
|
||||
includeQualifiedName: boolean;
|
||||
}
|
||||
|
||||
export function parseStreamQueryParams(
|
||||
query: Record<string, string | undefined>,
|
||||
): {
|
||||
sse: boolean;
|
||||
acquireTimeout: number;
|
||||
includeQualifiedName: boolean;
|
||||
} {
|
||||
return {
|
||||
sse: query.sse !== "false",
|
||||
acquireTimeout: query.acquire_timeout ? Number(query.acquire_timeout) : 1,
|
||||
includeQualifiedName: query.include_qualified_name !== "false",
|
||||
};
|
||||
}
|
||||
|
||||
export function formatEventAsSSE(event: StreamEvent): string {
|
||||
return `data: ${JSON.stringify(event)}\n\n`;
|
||||
}
|
||||
|
||||
export function formatEventAsNDJSON(event: StreamEvent): string {
|
||||
return `${JSON.stringify(event)}\n`;
|
||||
}
|
||||
|
||||
export function formatEvent(
|
||||
event: StreamEvent,
|
||||
options: StreamOptions,
|
||||
): string {
|
||||
const eventData: StreamEvent = options.includeQualifiedName
|
||||
? event
|
||||
: { type: event.type, data: event.data };
|
||||
|
||||
return options.sse
|
||||
? formatEventAsSSE(eventData)
|
||||
: formatEventAsNDJSON(eventData);
|
||||
}
|
||||
|
||||
export function formatCompletionEvent(
|
||||
status: string,
|
||||
result: unknown,
|
||||
error: string | undefined,
|
||||
): string {
|
||||
const completionEvent = {
|
||||
type: "__stream_complete__",
|
||||
status,
|
||||
result,
|
||||
error,
|
||||
};
|
||||
return formatEventAsSSE(completionEvent);
|
||||
}
|
||||
|
||||
export interface StreamGeneratorDeps {
|
||||
getQueuedEvents: (handlerId: string) => StreamEvent[] | undefined;
|
||||
getHandlerStatus: (handlerId: string) => string | undefined;
|
||||
getHandlerResult: (
|
||||
handlerId: string,
|
||||
) => { result: unknown; error: string | undefined } | undefined;
|
||||
releaseStreamLock: (handlerId: string) => void;
|
||||
pollInterval?: number;
|
||||
}
|
||||
|
||||
export async function* createStreamGenerator(
|
||||
handlerId: string,
|
||||
options: StreamOptions,
|
||||
deps: StreamGeneratorDeps,
|
||||
): AsyncGenerator<string> {
|
||||
const pollInterval = deps.pollInterval ?? 50;
|
||||
|
||||
try {
|
||||
// Keep streaming until handler completes or client disconnects
|
||||
while (true) {
|
||||
const events = deps.getQueuedEvents(handlerId);
|
||||
if (events && events.length > 0) {
|
||||
for (const event of events) {
|
||||
yield formatEvent(event, options);
|
||||
}
|
||||
}
|
||||
|
||||
const status = deps.getHandlerStatus(handlerId);
|
||||
if (!status || status !== "running") {
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
||||
}
|
||||
|
||||
// Send final completion event if SSE
|
||||
if (options.sse) {
|
||||
const handlerData = deps.getHandlerResult(handlerId);
|
||||
const status = deps.getHandlerStatus(handlerId);
|
||||
if (status && handlerData) {
|
||||
yield formatCompletionEvent(
|
||||
status,
|
||||
handlerData.result,
|
||||
handlerData.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
deps.releaseStreamLock(handlerId);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerStreamRoutes(
|
||||
fastify: FastifyInstance,
|
||||
ctx: StreamRoutesContext,
|
||||
): void {
|
||||
const { prefix, handlerStore } = ctx;
|
||||
|
||||
fastify.get(`${prefix}/events/:handlerId/stream`, {
|
||||
schema: {
|
||||
description:
|
||||
"Stream workflow events in SSE or NDJSON format. Only one consumer can stream at a time.",
|
||||
tags: ["Events"],
|
||||
params: toJsonSchema(HandlerIdParamsSchema),
|
||||
querystring: toJsonSchema(StreamQuerySchema),
|
||||
response: {
|
||||
404: toJsonSchema(ErrorResponseSchema),
|
||||
409: toJsonSchema(ErrorResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
request: FastifyRequest<{
|
||||
Params: z.infer<typeof HandlerIdParamsSchema>;
|
||||
Querystring: Record<string, string | undefined>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { handlerId } = request.params;
|
||||
const { sse, acquireTimeout, includeQualifiedName } =
|
||||
parseStreamQueryParams(request.query);
|
||||
|
||||
const handler = handlerStore.get(handlerId);
|
||||
if (!handler) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: `Handler "${handlerId}" not found` });
|
||||
}
|
||||
|
||||
const acquired = await handlerStore.acquireStreamLock(
|
||||
handlerId,
|
||||
acquireTimeout,
|
||||
);
|
||||
if (!acquired) {
|
||||
return reply.status(409).send({
|
||||
error: `Stream is already being consumed by another client. Try again later.`,
|
||||
});
|
||||
}
|
||||
|
||||
// Set up response headers
|
||||
if (sse) {
|
||||
reply.raw.setHeader("Content-Type", "text/event-stream");
|
||||
reply.raw.setHeader("Cache-Control", "no-cache");
|
||||
reply.raw.setHeader("Connection", "keep-alive");
|
||||
} else {
|
||||
reply.raw.setHeader("Content-Type", "application/x-ndjson");
|
||||
}
|
||||
|
||||
// Handle client disconnect
|
||||
request.raw.on("close", () => {
|
||||
handlerStore.releaseStreamLock(handlerId);
|
||||
});
|
||||
|
||||
const streamGenerator = createStreamGenerator(
|
||||
handlerId,
|
||||
{ sse, includeQualifiedName },
|
||||
{
|
||||
getQueuedEvents: (id) => handlerStore.getQueuedEvents(id),
|
||||
getHandlerStatus: (id) => handlerStore.get(id)?.info.status,
|
||||
getHandlerResult: (id) => {
|
||||
const h = handlerStore.get(id);
|
||||
return h
|
||||
? { result: h.info.result, error: h.info.error }
|
||||
: undefined;
|
||||
},
|
||||
releaseStreamLock: (id) => handlerStore.releaseStreamLock(id),
|
||||
},
|
||||
);
|
||||
|
||||
reply.raw.statusCode = 200;
|
||||
|
||||
for await (const chunk of streamGenerator) {
|
||||
reply.raw.write(chunk);
|
||||
}
|
||||
|
||||
reply.raw.end();
|
||||
return reply;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { WorkflowNotFoundError, WorkflowTimeoutError } from "../errors";
|
||||
import {
|
||||
ErrorResponseSchema,
|
||||
toJsonSchema,
|
||||
WorkflowNameParamsSchema,
|
||||
WorkflowRunAsyncResponseSchema,
|
||||
WorkflowRunRequestSchema,
|
||||
WorkflowRunResponseSchema,
|
||||
} from "../schemas";
|
||||
import type { RegisteredWorkflow, WorkflowRunAsyncResponse } from "../types";
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000;
|
||||
|
||||
interface WorkflowRoutesContext {
|
||||
prefix: string;
|
||||
getWorkflow: (name: string) => RegisteredWorkflow | undefined;
|
||||
names: () => string[];
|
||||
runWorkflow: <TStartData, TStopData>(
|
||||
name: string,
|
||||
data: TStartData,
|
||||
timeout?: number,
|
||||
) => Promise<TStopData>;
|
||||
runWorkflowAsync: (name: string, data: unknown) => WorkflowRunAsyncResponse;
|
||||
}
|
||||
|
||||
export function registerWorkflowRoutes(
|
||||
fastify: FastifyInstance,
|
||||
ctx: WorkflowRoutesContext,
|
||||
): void {
|
||||
const { prefix } = ctx;
|
||||
|
||||
fastify.get(`${prefix}/workflows`, {
|
||||
schema: {
|
||||
description: "List all registered workflow names",
|
||||
tags: ["Workflows"],
|
||||
response: {
|
||||
200: toJsonSchema(z.array(z.string())),
|
||||
},
|
||||
},
|
||||
handler: async () => ctx.names(),
|
||||
});
|
||||
|
||||
fastify.post(`${prefix}/workflows/:name/run`, {
|
||||
schema: {
|
||||
description: "Run a workflow synchronously and wait for completion",
|
||||
tags: ["Workflows"],
|
||||
params: toJsonSchema(WorkflowNameParamsSchema),
|
||||
body: toJsonSchema(WorkflowRunRequestSchema),
|
||||
response: {
|
||||
200: toJsonSchema(WorkflowRunResponseSchema),
|
||||
404: toJsonSchema(ErrorResponseSchema),
|
||||
408: toJsonSchema(ErrorResponseSchema),
|
||||
500: toJsonSchema(ErrorResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
request: FastifyRequest<{
|
||||
Params: z.infer<typeof WorkflowNameParamsSchema>;
|
||||
Body: z.infer<typeof WorkflowRunRequestSchema>;
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { name } = request.params;
|
||||
const { data, timeout = DEFAULT_TIMEOUT } = request.body;
|
||||
|
||||
if (!ctx.getWorkflow(name)) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: `Workflow "${name}" not found` });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.runWorkflow(name, data, timeout);
|
||||
return { result };
|
||||
} catch (error) {
|
||||
if (error instanceof WorkflowNotFoundError) {
|
||||
return reply.status(404).send({ error: error.message });
|
||||
}
|
||||
if (error instanceof WorkflowTimeoutError) {
|
||||
return reply.status(408).send({ error: error.message });
|
||||
}
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
return reply.status(500).send({ error: message });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
fastify.post(`${prefix}/workflows/:name/run-nowait`, {
|
||||
schema: {
|
||||
description: "Start a workflow asynchronously and return immediately",
|
||||
tags: ["Workflows"],
|
||||
params: toJsonSchema(WorkflowNameParamsSchema),
|
||||
body: toJsonSchema(WorkflowRunRequestSchema.omit({ timeout: true })),
|
||||
response: {
|
||||
202: toJsonSchema(WorkflowRunAsyncResponseSchema),
|
||||
404: toJsonSchema(ErrorResponseSchema),
|
||||
},
|
||||
},
|
||||
handler: async (
|
||||
request: FastifyRequest<{
|
||||
Params: z.infer<typeof WorkflowNameParamsSchema>;
|
||||
Body: { data: unknown };
|
||||
}>,
|
||||
reply: FastifyReply,
|
||||
) => {
|
||||
const { name } = request.params;
|
||||
const { data } = request.body;
|
||||
|
||||
if (!ctx.getWorkflow(name)) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: `Workflow "${name}" not found` });
|
||||
}
|
||||
|
||||
const response = ctx.runWorkflowAsync(name, data);
|
||||
return reply.status(202).send(response);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { z } from "zod";
|
||||
import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
|
||||
type JsonSchema = ReturnType<typeof zodToJsonSchema>;
|
||||
|
||||
export function toJsonSchema(schema: z.ZodType): JsonSchema {
|
||||
return zodToJsonSchema(schema, { $refStrategy: "none" });
|
||||
}
|
||||
|
||||
// Common schemas
|
||||
export const ErrorResponseSchema = z.object({
|
||||
error: z.string(),
|
||||
});
|
||||
export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
|
||||
|
||||
// Health
|
||||
export const HealthResponseSchema = z.object({
|
||||
status: z.literal("ok"),
|
||||
});
|
||||
export type HealthResponse = z.infer<typeof HealthResponseSchema>;
|
||||
|
||||
// Handler
|
||||
export const HandlerStatusSchema = z.enum([
|
||||
"running",
|
||||
"completed",
|
||||
"error",
|
||||
"cancelled",
|
||||
]);
|
||||
export type HandlerStatus = z.infer<typeof HandlerStatusSchema>;
|
||||
|
||||
export const HandlerInfoSchema = z.object({
|
||||
handlerId: z.string(),
|
||||
workflowName: z.string(),
|
||||
status: HandlerStatusSchema,
|
||||
startedAt: z.coerce.date(),
|
||||
completedAt: z.coerce.date().optional(),
|
||||
result: z.unknown().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type HandlerInfo = z.infer<typeof HandlerInfoSchema>;
|
||||
|
||||
// Workflow run
|
||||
export const WorkflowRunRequestSchema = z.object({
|
||||
data: z.unknown(),
|
||||
timeout: z.number().optional().default(30000),
|
||||
});
|
||||
export type WorkflowRunRequest = z.infer<typeof WorkflowRunRequestSchema>;
|
||||
|
||||
export const WorkflowRunResponseSchema = z.object({
|
||||
result: z.unknown(),
|
||||
});
|
||||
export type WorkflowRunResponse = z.infer<typeof WorkflowRunResponseSchema>;
|
||||
|
||||
export const WorkflowRunAsyncResponseSchema = z.object({
|
||||
handlerId: z.string(),
|
||||
status: z.literal("running"),
|
||||
});
|
||||
export type WorkflowRunAsyncResponse = z.infer<
|
||||
typeof WorkflowRunAsyncResponseSchema
|
||||
>;
|
||||
|
||||
// Route params
|
||||
export const WorkflowNameParamsSchema = z.object({
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const HandlerIdParamsSchema = z.object({
|
||||
handlerId: z.string(),
|
||||
});
|
||||
|
||||
// Query params
|
||||
export const HandlersQuerySchema = z.object({
|
||||
status: HandlerStatusSchema.optional(),
|
||||
workflow_name: z.string().optional(),
|
||||
});
|
||||
|
||||
// Event schemas
|
||||
export const EventSchemaSchema = z.object({
|
||||
uniqueId: z.string(),
|
||||
debugLabel: z.string().optional(),
|
||||
schema: z.record(z.unknown()).optional(),
|
||||
});
|
||||
export type EventSchema = z.infer<typeof EventSchemaSchema>;
|
||||
|
||||
export const WorkflowSchemaResponseSchema = z.object({
|
||||
startEvent: EventSchemaSchema,
|
||||
stopEvent: EventSchemaSchema,
|
||||
});
|
||||
export type WorkflowSchemaResponse = z.infer<
|
||||
typeof WorkflowSchemaResponseSchema
|
||||
>;
|
||||
|
||||
export const WorkflowEventsResponseSchema = z.array(EventSchemaSchema);
|
||||
export type WorkflowEventsResponse = z.infer<
|
||||
typeof WorkflowEventsResponseSchema
|
||||
>;
|
||||
|
||||
export const SendEventRequestSchema = z.object({
|
||||
eventType: z.string(),
|
||||
data: z.unknown(),
|
||||
});
|
||||
export type SendEventRequest = z.infer<typeof SendEventRequestSchema>;
|
||||
|
||||
export const SendEventResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
export type SendEventResponse = z.infer<typeof SendEventResponseSchema>;
|
||||
|
||||
export const StreamQuerySchema = z.object({
|
||||
sse: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("true")
|
||||
.transform((v) => v !== "false"),
|
||||
include_internal: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("false")
|
||||
.transform((v) => v === "true"),
|
||||
acquire_timeout: z.coerce.number().optional().default(1),
|
||||
include_qualified_name: z
|
||||
.string()
|
||||
.optional()
|
||||
.default("true")
|
||||
.transform((v) => v !== "false"),
|
||||
});
|
||||
export type StreamQuery = z.infer<typeof StreamQuerySchema>;
|
||||
|
||||
export const StreamEventSchema = z.object({
|
||||
type: z.string(),
|
||||
data: z.unknown(),
|
||||
qualified_name: z.string().optional(),
|
||||
});
|
||||
export type StreamEvent = z.infer<typeof StreamEventSchema>;
|
||||
|
||||
export const CancelQuerySchema = z.object({
|
||||
purge: z
|
||||
.string()
|
||||
.transform((v) => v === "true")
|
||||
.optional()
|
||||
.default("false"),
|
||||
});
|
||||
export type CancelQuery = z.infer<typeof CancelQuerySchema>;
|
||||
|
||||
export const CancelResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
message: z.string(),
|
||||
handler_id: z.string(),
|
||||
});
|
||||
export type CancelResponse = z.infer<typeof CancelResponseSchema>;
|
||||
@@ -0,0 +1,978 @@
|
||||
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
|
||||
import Fastify from "fastify";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createWorkflowServer,
|
||||
fastifyPlugin,
|
||||
WorkflowNotFoundError,
|
||||
WorkflowServer,
|
||||
WorkflowTimeoutError,
|
||||
} from "./server";
|
||||
|
||||
const startEvent = workflowEvent<string>();
|
||||
const stopEvent = workflowEvent<string>();
|
||||
const inputEvent = workflowEvent<{ value: number }>();
|
||||
const outputEvent = workflowEvent<{ result: number }>();
|
||||
|
||||
function createEchoWorkflow() {
|
||||
const workflow = createWorkflow();
|
||||
workflow.handle([startEvent], (_context, event) => {
|
||||
return stopEvent.with(`Echo: ${event.data}`);
|
||||
});
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function createDoubleWorkflow() {
|
||||
const workflow = createWorkflow();
|
||||
workflow.handle([inputEvent], (_context, event) => {
|
||||
return outputEvent.with({ result: event.data.value * 2 });
|
||||
});
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function createSlowWorkflow(delay: number) {
|
||||
const workflow = createWorkflow();
|
||||
workflow.handle([startEvent], async (_context, event) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return stopEvent.with(`Slow: ${event.data}`);
|
||||
});
|
||||
return workflow;
|
||||
}
|
||||
|
||||
describe("WorkflowServer", () => {
|
||||
describe("constructor and configuration", () => {
|
||||
it("should create a server with default options", () => {
|
||||
const server = new WorkflowServer();
|
||||
expect(server).toBeInstanceOf(WorkflowServer);
|
||||
});
|
||||
|
||||
it("should create a server with custom options", () => {
|
||||
const server = new WorkflowServer({ prefix: "/api/v1" });
|
||||
expect(server).toBeInstanceOf(WorkflowServer);
|
||||
});
|
||||
|
||||
it("should create a server using factory function", () => {
|
||||
const server = createWorkflowServer();
|
||||
expect(server).toBeInstanceOf(WorkflowServer);
|
||||
});
|
||||
|
||||
it("should create a server with initial workflows using factory function", () => {
|
||||
const server = createWorkflowServer({
|
||||
echo: {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
},
|
||||
double: {
|
||||
workflow: createDoubleWorkflow(),
|
||||
startEvent: inputEvent,
|
||||
stopEvent: outputEvent,
|
||||
},
|
||||
});
|
||||
expect(server.names()).toEqual(["echo", "double"]);
|
||||
});
|
||||
|
||||
it("should create a server with initial workflows and options", () => {
|
||||
const server = createWorkflowServer(
|
||||
{
|
||||
echo: {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
},
|
||||
},
|
||||
{ prefix: "/api/v1" },
|
||||
);
|
||||
expect(server.names()).toEqual(["echo"]);
|
||||
expect(server.options.prefix).toBe("/api/v1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflow registration", () => {
|
||||
it("should register a workflow using register method", () => {
|
||||
const server = new WorkflowServer();
|
||||
server.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
expect(server.names()).toEqual(["echo"]);
|
||||
});
|
||||
|
||||
it("should support chaining with register method", () => {
|
||||
const server = new WorkflowServer();
|
||||
server
|
||||
.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
})
|
||||
.register("double", {
|
||||
workflow: createDoubleWorkflow(),
|
||||
startEvent: inputEvent,
|
||||
stopEvent: outputEvent,
|
||||
});
|
||||
expect(server.names()).toEqual(["echo", "double"]);
|
||||
});
|
||||
|
||||
it("should throw error when registering duplicate workflow", () => {
|
||||
const server = new WorkflowServer();
|
||||
const workflow = createEchoWorkflow();
|
||||
server.register("echo", { workflow, startEvent, stopEvent });
|
||||
expect(() => {
|
||||
server.register("echo", { workflow, startEvent, stopEvent });
|
||||
}).toThrow('Workflow "echo" is already registered');
|
||||
});
|
||||
|
||||
it("should get workflow by name", () => {
|
||||
const server = new WorkflowServer();
|
||||
const workflow = createEchoWorkflow();
|
||||
server.register("echo", { workflow, startEvent, stopEvent });
|
||||
const registered = server.getWorkflow("echo");
|
||||
expect(registered).toBeDefined();
|
||||
expect(registered?.name).toBe("echo");
|
||||
expect(registered?.workflow).toBe(workflow);
|
||||
});
|
||||
|
||||
it("should return undefined for unknown workflow", () => {
|
||||
const server = new WorkflowServer();
|
||||
expect(server.getWorkflow("unknown")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWorkflow", () => {
|
||||
it("should run a workflow and return result", async () => {
|
||||
const server = new WorkflowServer();
|
||||
server.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
const result = await server.runWorkflow("echo", "Hello");
|
||||
expect(result).toBe("Echo: Hello");
|
||||
});
|
||||
|
||||
it("should run a workflow with object data", async () => {
|
||||
const server = new WorkflowServer();
|
||||
server.register("double", {
|
||||
workflow: createDoubleWorkflow(),
|
||||
startEvent: inputEvent,
|
||||
stopEvent: outputEvent,
|
||||
});
|
||||
const result = await server.runWorkflow<
|
||||
{ value: number },
|
||||
{ result: number }
|
||||
>("double", { value: 21 });
|
||||
expect(result).toEqual({ result: 42 });
|
||||
});
|
||||
|
||||
it("should throw WorkflowNotFoundError for unknown workflow", async () => {
|
||||
const server = new WorkflowServer();
|
||||
await expect(server.runWorkflow("unknown", "data")).rejects.toThrow(
|
||||
WorkflowNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it("should timeout slow workflows", async () => {
|
||||
const server = new WorkflowServer();
|
||||
server.register("slow", {
|
||||
workflow: createSlowWorkflow(500),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
await expect(server.runWorkflow("slow", "data", 100)).rejects.toThrow(
|
||||
WorkflowTimeoutError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runWorkflowAsync", () => {
|
||||
it("should start workflow and return handler info", () => {
|
||||
const server = new WorkflowServer();
|
||||
server.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
const response = server.runWorkflowAsync("echo", "Hello");
|
||||
expect(response.handlerId).toBeDefined();
|
||||
expect(response.status).toBe("running");
|
||||
});
|
||||
|
||||
it("should track handler status", async () => {
|
||||
const server = new WorkflowServer();
|
||||
server.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
const { handlerId } = server.runWorkflowAsync("echo", "Hello");
|
||||
|
||||
// Wait for completion
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const handler = server.getHandler(handlerId);
|
||||
expect(handler?.status).toBe("completed");
|
||||
expect(handler?.result).toBe("Echo: Hello");
|
||||
});
|
||||
|
||||
it("should throw WorkflowNotFoundError for unknown workflow", () => {
|
||||
const server = new WorkflowServer();
|
||||
expect(() => server.runWorkflowAsync("unknown", "data")).toThrow(
|
||||
WorkflowNotFoundError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getHandlers", () => {
|
||||
it("should list all handlers", async () => {
|
||||
const server = new WorkflowServer();
|
||||
server.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
server.runWorkflowAsync("echo", "Hello1");
|
||||
server.runWorkflowAsync("echo", "Hello2");
|
||||
|
||||
const handlers = server.getHandlers();
|
||||
expect(handlers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should filter handlers by status", async () => {
|
||||
const server = new WorkflowServer();
|
||||
server.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
server.register("slow", {
|
||||
workflow: createSlowWorkflow(200),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
|
||||
server.runWorkflowAsync("echo", "fast");
|
||||
server.runWorkflowAsync("slow", "slow");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const running = server.getHandlers({ status: "running" });
|
||||
const completed = server.getHandlers({ status: "completed" });
|
||||
|
||||
expect(running).toHaveLength(1);
|
||||
expect(completed).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should filter handlers by workflow name", async () => {
|
||||
const server = new WorkflowServer();
|
||||
server.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
server.register("double", {
|
||||
workflow: createDoubleWorkflow(),
|
||||
startEvent: inputEvent,
|
||||
stopEvent: outputEvent,
|
||||
});
|
||||
|
||||
server.runWorkflowAsync("echo", "test");
|
||||
server.runWorkflowAsync("double", { value: 1 });
|
||||
|
||||
const echoHandlers = server.getHandlers({ workflowName: "echo" });
|
||||
expect(echoHandlers).toHaveLength(1);
|
||||
expect(echoHandlers[0]?.workflowName).toBe("echo");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("WorkflowServer HTTP endpoints", () => {
|
||||
let server: WorkflowServer;
|
||||
let app: ReturnType<typeof Fastify>;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = new WorkflowServer();
|
||||
app = Fastify();
|
||||
|
||||
server.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
server.register("double", {
|
||||
workflow: createDoubleWorkflow(),
|
||||
startEvent: inputEvent,
|
||||
stopEvent: outputEvent,
|
||||
});
|
||||
|
||||
await app.register(fastifyPlugin(server));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe("GET /health", () => {
|
||||
it("should return health status", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/health",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ status: "ok" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /workflows", () => {
|
||||
it("should return list of workflow names", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/workflows",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual(["echo", "double"]);
|
||||
});
|
||||
|
||||
it("should return empty array when no workflows registered", async () => {
|
||||
const emptyServer = new WorkflowServer();
|
||||
const emptyApp = Fastify();
|
||||
await emptyApp.register(fastifyPlugin(emptyServer));
|
||||
|
||||
const response = await emptyApp.inject({
|
||||
method: "GET",
|
||||
url: "/workflows",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([]);
|
||||
await emptyApp.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /workflows/:name/run", () => {
|
||||
it("should run a workflow and return result", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run",
|
||||
payload: { data: "Hello World" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ result: "Echo: Hello World" });
|
||||
});
|
||||
|
||||
it("should run a workflow with object data", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/double/run",
|
||||
payload: { data: { value: 21 } },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ result: { result: 42 } });
|
||||
});
|
||||
|
||||
it("should return 404 for unknown workflow", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/unknown/run",
|
||||
payload: { data: "test" },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({
|
||||
error: 'Workflow "unknown" not found',
|
||||
});
|
||||
});
|
||||
|
||||
it("should respect custom timeout", async () => {
|
||||
server.register("slow", {
|
||||
workflow: createSlowWorkflow(500),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/slow/run",
|
||||
payload: { data: "test", timeout: 100 },
|
||||
});
|
||||
expect(response.statusCode).toBe(408);
|
||||
expect(response.json().error).toContain("timed out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /workflows/:name/run-nowait", () => {
|
||||
it("should start workflow and return 202 with handler id", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "Hello" },
|
||||
});
|
||||
expect(response.statusCode).toBe(202);
|
||||
const body = response.json();
|
||||
expect(body.handlerId).toBeDefined();
|
||||
expect(body.status).toBe("running");
|
||||
});
|
||||
|
||||
it("should return 404 for unknown workflow", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/unknown/run-nowait",
|
||||
payload: { data: "test" },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /handlers", () => {
|
||||
it("should return empty array when no handlers", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/handlers",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return list of handlers", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "test1" },
|
||||
});
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "test2" },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/handlers",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("should filter by status", async () => {
|
||||
server.register("slow", {
|
||||
workflow: createSlowWorkflow(200),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "fast" },
|
||||
});
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/slow/run-nowait",
|
||||
payload: { data: "slow" },
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const runningResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/handlers?status=running",
|
||||
});
|
||||
expect(runningResponse.json()).toHaveLength(1);
|
||||
|
||||
const completedResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/handlers?status=completed",
|
||||
});
|
||||
expect(completedResponse.json()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should filter by workflow_name", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "test" },
|
||||
});
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/double/run-nowait",
|
||||
payload: { data: { value: 1 } },
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/handlers?workflow_name=echo",
|
||||
});
|
||||
expect(response.json()).toHaveLength(1);
|
||||
expect(response.json()[0].workflowName).toBe("echo");
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /handlers/:handlerId", () => {
|
||||
it("should return 404 for unknown handler", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/handlers/unknown-id",
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 202 for running handler", async () => {
|
||||
server.register("slow", {
|
||||
workflow: createSlowWorkflow(200),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/slow/run-nowait",
|
||||
payload: { data: "test" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/handlers/${handlerId}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(202);
|
||||
expect(response.json().status).toBe("running");
|
||||
});
|
||||
|
||||
it("should return 200 with result for completed handler", async () => {
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "Hello" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/handlers/${handlerId}`,
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json().status).toBe("completed");
|
||||
expect(response.json().result).toBe("Echo: Hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("with prefix", () => {
|
||||
it("should handle routes with prefix", async () => {
|
||||
const prefixedServer = new WorkflowServer({ prefix: "/api/v1" });
|
||||
prefixedServer.register("echo", {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
|
||||
const prefixedApp = Fastify();
|
||||
await prefixedApp.register(fastifyPlugin(prefixedServer));
|
||||
|
||||
const healthResponse = await prefixedApp.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/health",
|
||||
});
|
||||
expect(healthResponse.statusCode).toBe(200);
|
||||
|
||||
const runNowaitResponse = await prefixedApp.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/workflows/echo/run-nowait",
|
||||
payload: { data: "Prefixed" },
|
||||
});
|
||||
expect(runNowaitResponse.statusCode).toBe(202);
|
||||
|
||||
const handlersResponse = await prefixedApp.inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/handlers",
|
||||
});
|
||||
expect(handlersResponse.statusCode).toBe(200);
|
||||
|
||||
await prefixedApp.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fastifyPlugin function", () => {
|
||||
it("should work as standalone plugin function", async () => {
|
||||
const workflowServer = createWorkflowServer({
|
||||
echo: {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
},
|
||||
});
|
||||
|
||||
const testApp = Fastify();
|
||||
await testApp.register(fastifyPlugin(workflowServer));
|
||||
|
||||
const response = await testApp.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run",
|
||||
payload: { data: "Test" },
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ result: "Echo: Test" });
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
|
||||
it("should handle prefix with fastifyPlugin", async () => {
|
||||
const workflowServer = createWorkflowServer(
|
||||
{
|
||||
echo: {
|
||||
workflow: createEchoWorkflow(),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
},
|
||||
},
|
||||
{ prefix: "/api" },
|
||||
);
|
||||
|
||||
const testApp = Fastify();
|
||||
await testApp.register(fastifyPlugin(workflowServer));
|
||||
|
||||
const response = await testApp.inject({
|
||||
method: "GET",
|
||||
url: "/api/health",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
await testApp.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /workflows/:name/schema", () => {
|
||||
it("should return schema for start and stop events", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/workflows/echo/schema",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body.startEvent).toBeDefined();
|
||||
expect(body.startEvent.uniqueId).toBeDefined();
|
||||
expect(body.stopEvent).toBeDefined();
|
||||
expect(body.stopEvent.uniqueId).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return 404 for unknown workflow", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/workflows/unknown/schema",
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json()).toEqual({
|
||||
error: 'Workflow "unknown" not found',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /workflows/:name/events", () => {
|
||||
it("should return all events for a workflow", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/workflows/echo/events",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body).toHaveLength(2); // start and stop events
|
||||
expect(body[0].uniqueId).toBeDefined();
|
||||
expect(body[1].uniqueId).toBeDefined();
|
||||
});
|
||||
|
||||
it("should return 404 for unknown workflow", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/workflows/unknown/events",
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("should include additional events when registered", async () => {
|
||||
const additionalEvent = workflowEvent<{ message: string }>({
|
||||
debugLabel: "additionalEvent",
|
||||
});
|
||||
const additionalWorkflow = createWorkflow();
|
||||
additionalWorkflow.handle([startEvent], (_context, event) => {
|
||||
return stopEvent.with(`Echo: ${event.data}`);
|
||||
});
|
||||
|
||||
server.register("withAdditional", {
|
||||
workflow: additionalWorkflow,
|
||||
startEvent,
|
||||
stopEvent,
|
||||
additionalEvents: [additionalEvent],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/workflows/withAdditional/events",
|
||||
});
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = response.json();
|
||||
expect(body).toHaveLength(3); // start, stop, and additional
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /events/:handlerId", () => {
|
||||
it("should return 404 for unknown handler", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/events/unknown-handler",
|
||||
payload: { eventType: "test", data: {} },
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
expect(response.json().error).toContain("not found");
|
||||
});
|
||||
|
||||
it("should return 400 for non-running handler", async () => {
|
||||
// Start a fast workflow
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "Hello" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
|
||||
// Wait for it to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
|
||||
// Try to send event to completed handler
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/events/${handlerId}`,
|
||||
payload: { eventType: startEvent.uniqueId, data: "test" },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain("not running");
|
||||
});
|
||||
|
||||
it("should return 400 for unknown event type", async () => {
|
||||
server.register("slow", {
|
||||
workflow: createSlowWorkflow(500),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/slow/run-nowait",
|
||||
payload: { data: "test" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/events/${handlerId}`,
|
||||
payload: { eventType: "unknown-event", data: {} },
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain("Event type");
|
||||
});
|
||||
|
||||
it("should successfully send event to running handler", async () => {
|
||||
// Create a workflow that waits for an additional event
|
||||
const waitEvent = workflowEvent<string>({ debugLabel: "waitEvent" });
|
||||
const continueWorkflow = createWorkflow();
|
||||
|
||||
continueWorkflow.handle([startEvent], async (context, event) => {
|
||||
// Wait for the additional event before completing
|
||||
for await (const e of context.stream) {
|
||||
if (waitEvent.include(e)) {
|
||||
return stopEvent.with(`Completed with: ${e.data}`);
|
||||
}
|
||||
}
|
||||
return stopEvent.with(`No wait event: ${event.data}`);
|
||||
});
|
||||
|
||||
server.register("continue", {
|
||||
workflow: continueWorkflow,
|
||||
startEvent,
|
||||
stopEvent,
|
||||
additionalEvents: [waitEvent],
|
||||
});
|
||||
|
||||
// Start the workflow
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/continue/run-nowait",
|
||||
payload: { data: "initial" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
|
||||
// Verify handler is running
|
||||
const statusResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/handlers/${handlerId}`,
|
||||
});
|
||||
expect(statusResponse.statusCode).toBe(202);
|
||||
|
||||
// Send the wait event
|
||||
const sendResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/events/${handlerId}`,
|
||||
payload: { eventType: waitEvent.uniqueId, data: "continue data" },
|
||||
});
|
||||
expect(sendResponse.statusCode).toBe(200);
|
||||
expect(sendResponse.json().success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /events/:handlerId/stream", () => {
|
||||
it("should return 404 for unknown handler", async () => {
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: "/events/unknown-handler/stream",
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("should stream events in SSE format by default", async () => {
|
||||
// Start a workflow
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "Hello" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
|
||||
// Wait a bit for the workflow to complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Stream the events
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/events/${handlerId}/stream`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain("text/event-stream");
|
||||
});
|
||||
|
||||
it("should stream events in NDJSON format when sse=false", async () => {
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "Hello" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/events/${handlerId}/stream?sse=false`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain(
|
||||
"application/x-ndjson",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /handlers/:handlerId/cancel", () => {
|
||||
it("should return 404 for unknown handler", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/handlers/unknown-handler/cancel",
|
||||
});
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("should return 400 for already completed handler", async () => {
|
||||
// Start and wait for completion
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/echo/run-nowait",
|
||||
payload: { data: "Hello" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
|
||||
// Wait for completion
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Try to cancel
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/handlers/${handlerId}/cancel`,
|
||||
});
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error).toContain("not running");
|
||||
});
|
||||
|
||||
it("should cancel a running handler", async () => {
|
||||
const workflowName = `cancelTestWorkflow_${Date.now()}`;
|
||||
server.register(workflowName, {
|
||||
workflow: createSlowWorkflow(5000),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/workflows/${workflowName}/run-nowait`,
|
||||
payload: { data: "test" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
expect(handlerId).toBeDefined();
|
||||
|
||||
// Verify it's running
|
||||
const statusResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/handlers/${handlerId}`,
|
||||
});
|
||||
expect(statusResponse.statusCode).toBe(202);
|
||||
expect(statusResponse.json().status).toBe("running");
|
||||
|
||||
// Cancel it
|
||||
const cancelResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/handlers/${handlerId}/cancel`,
|
||||
});
|
||||
expect(cancelResponse.statusCode).toBe(200);
|
||||
expect(cancelResponse.json().success).toBe(true);
|
||||
|
||||
// Check handler list to see if handler still exists
|
||||
const handlersResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/handlers`,
|
||||
});
|
||||
const handlers = handlersResponse.json();
|
||||
const ourHandler = handlers.find(
|
||||
(h: { handlerId: string }) => h.handlerId === handlerId,
|
||||
);
|
||||
expect(ourHandler).toBeDefined();
|
||||
expect(ourHandler.status).toBe("cancelled");
|
||||
});
|
||||
|
||||
it("should cancel and purge a handler when purge=true", async () => {
|
||||
server.register("anotherSlowWorkflow", {
|
||||
workflow: createSlowWorkflow(5000),
|
||||
startEvent,
|
||||
stopEvent,
|
||||
});
|
||||
|
||||
const startResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/workflows/anotherSlowWorkflow/run-nowait",
|
||||
payload: { data: "test" },
|
||||
});
|
||||
const { handlerId } = startResponse.json();
|
||||
|
||||
// Cancel with purge
|
||||
const cancelResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/handlers/${handlerId}/cancel?purge=true`,
|
||||
});
|
||||
expect(cancelResponse.statusCode).toBe(200);
|
||||
expect(cancelResponse.json().message).toContain("purged");
|
||||
|
||||
// Handler should be gone
|
||||
const statusResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/handlers/${handlerId}`,
|
||||
});
|
||||
expect(statusResponse.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync } from "fastify";
|
||||
import { WorkflowNotFoundError, WorkflowTimeoutError } from "./errors";
|
||||
import { createHandlerStore, type HandlerStore } from "./handler-store";
|
||||
import {
|
||||
registerEventRoutes,
|
||||
registerHandlerRoutes,
|
||||
registerHealthRoutes,
|
||||
registerStreamRoutes,
|
||||
registerWorkflowRoutes,
|
||||
} from "./routes";
|
||||
import type { HandlerStatus, WorkflowRunAsyncResponse } from "./schemas";
|
||||
import type {
|
||||
RegisteredWorkflow,
|
||||
WorkflowConfig,
|
||||
WorkflowServerOptions,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
HandlerNotFoundError,
|
||||
WorkflowNotFoundError,
|
||||
WorkflowTimeoutError,
|
||||
} from "./errors";
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000;
|
||||
|
||||
function generateHandlerId(): string {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A map of workflow names to their configurations for initial registration.
|
||||
*/
|
||||
export type WorkflowsConfig = Record<string, WorkflowConfig>;
|
||||
|
||||
export class WorkflowServer {
|
||||
private workflows: Map<string, RegisteredWorkflow> = new Map();
|
||||
private _handlerStore: HandlerStore = createHandlerStore();
|
||||
private _options: Required<WorkflowServerOptions>;
|
||||
|
||||
constructor(options: WorkflowServerOptions = {}) {
|
||||
this._options = {
|
||||
prefix: options.prefix ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the server options (read-only).
|
||||
*/
|
||||
get options(): Readonly<Required<WorkflowServerOptions>> {
|
||||
return this._options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the handler store (for internal use).
|
||||
* @internal
|
||||
*/
|
||||
get handlerStore(): HandlerStore {
|
||||
return this._handlerStore;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a workflow with the server.
|
||||
* @param name - The unique name for this workflow
|
||||
* @param config - The workflow configuration including workflow, startEvent, and stopEvent
|
||||
*/
|
||||
register<TStartData = unknown, TStopData = unknown>(
|
||||
name: string,
|
||||
config: WorkflowConfig<TStartData, TStopData>,
|
||||
): this {
|
||||
if (this.workflows.has(name)) {
|
||||
throw new Error(`Workflow "${name}" is already registered`);
|
||||
}
|
||||
|
||||
this.workflows.set(name, {
|
||||
...config,
|
||||
name,
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
getWorkflow(name: string): RegisteredWorkflow | undefined {
|
||||
return this.workflows.get(name);
|
||||
}
|
||||
|
||||
names(): string[] {
|
||||
return Array.from(this.workflows.keys());
|
||||
}
|
||||
|
||||
getHandler(handlerId: string) {
|
||||
return this._handlerStore.getInfo(handlerId);
|
||||
}
|
||||
|
||||
getHandlers(filters?: { status?: HandlerStatus; workflowName?: string }) {
|
||||
return this._handlerStore.list(filters);
|
||||
}
|
||||
|
||||
async runWorkflow<TStartData, TStopData>(
|
||||
name: string,
|
||||
data: TStartData,
|
||||
timeout: number = DEFAULT_TIMEOUT,
|
||||
): Promise<TStopData> {
|
||||
const registered = this.workflows.get(name);
|
||||
if (!registered) {
|
||||
throw new WorkflowNotFoundError(name);
|
||||
}
|
||||
|
||||
const { workflow, startEvent, stopEvent } =
|
||||
registered as RegisteredWorkflow<TStartData, TStopData>;
|
||||
|
||||
const { stream, sendEvent } = workflow.createContext();
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
setTimeout(() => {
|
||||
reject(new WorkflowTimeoutError(name, timeout));
|
||||
}, timeout);
|
||||
});
|
||||
|
||||
sendEvent(startEvent.with(data));
|
||||
|
||||
const resultPromise = (async () => {
|
||||
for await (const event of stream) {
|
||||
if (stopEvent.include(event)) {
|
||||
return event.data as TStopData;
|
||||
}
|
||||
}
|
||||
throw new Error(`Workflow "${name}" completed without stop event`);
|
||||
})();
|
||||
|
||||
return Promise.race([resultPromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
runWorkflowAsync(name: string, data: unknown): WorkflowRunAsyncResponse {
|
||||
const registered = this.workflows.get(name);
|
||||
if (!registered) {
|
||||
throw new WorkflowNotFoundError(name);
|
||||
}
|
||||
|
||||
const handlerId = generateHandlerId();
|
||||
const { workflow, startEvent, stopEvent } = registered;
|
||||
const context = workflow.createContext();
|
||||
const { stream, sendEvent } = context;
|
||||
|
||||
this._handlerStore.create(handlerId, name, context, sendEvent);
|
||||
|
||||
sendEvent(startEvent.with(data));
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
for await (const event of stream) {
|
||||
// Push event to queue for streaming
|
||||
this._handlerStore.pushEvent(handlerId, {
|
||||
type: event.toString(),
|
||||
data: event.data,
|
||||
qualified_name: event.toString(),
|
||||
});
|
||||
|
||||
if (stopEvent.include(event)) {
|
||||
this._handlerStore.updateStatus(handlerId, "completed", {
|
||||
result: event.data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._handlerStore.updateStatus(handlerId, "error", {
|
||||
error: "Workflow completed without stop event",
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
this._handlerStore.updateStatus(handlerId, "error", { error: message });
|
||||
}
|
||||
})();
|
||||
|
||||
return { handlerId, status: "running" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Fastify plugin from a WorkflowServer instance.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const workflowServer = createWorkflowServer({ ... });
|
||||
* await app.register(fastifyPlugin(workflowServer));
|
||||
* ```
|
||||
*/
|
||||
export function fastifyPlugin(server: WorkflowServer): FastifyPluginAsync {
|
||||
return async (fastify: FastifyInstance) => {
|
||||
const { prefix } = server.options;
|
||||
registerHealthRoutes(fastify, prefix);
|
||||
registerWorkflowRoutes(fastify, {
|
||||
prefix,
|
||||
getWorkflow: (name) => server.getWorkflow(name),
|
||||
names: () => server.names(),
|
||||
runWorkflow: (name, data, timeout) =>
|
||||
server.runWorkflow(name, data, timeout),
|
||||
runWorkflowAsync: (name, data) => server.runWorkflowAsync(name, data),
|
||||
});
|
||||
registerHandlerRoutes(fastify, {
|
||||
prefix,
|
||||
handlerStore: server.handlerStore,
|
||||
});
|
||||
registerEventRoutes(fastify, {
|
||||
prefix,
|
||||
getWorkflow: (name) => server.getWorkflow(name),
|
||||
handlerStore: server.handlerStore,
|
||||
});
|
||||
registerStreamRoutes(fastify, {
|
||||
prefix,
|
||||
handlerStore: server.handlerStore,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new WorkflowServer with optional initial workflow registrations.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Create with initial workflows
|
||||
* const server = createWorkflowServer({
|
||||
* greeting: {
|
||||
* workflow: greetingWorkflow,
|
||||
* startEvent: greetStartEvent,
|
||||
* stopEvent: greetStopEvent,
|
||||
* },
|
||||
* calculator: {
|
||||
* workflow: calculatorWorkflow,
|
||||
* startEvent: calcInputEvent,
|
||||
* stopEvent: calcOutputEvent,
|
||||
* },
|
||||
* });
|
||||
*
|
||||
* // Or create empty and register later
|
||||
* const server = createWorkflowServer();
|
||||
* server.register("greeting", { ... });
|
||||
* ```
|
||||
*/
|
||||
export function createWorkflowServer(
|
||||
workflows?: WorkflowsConfig,
|
||||
options?: WorkflowServerOptions,
|
||||
): WorkflowServer {
|
||||
const server = new WorkflowServer(options);
|
||||
|
||||
if (workflows) {
|
||||
for (const [name, config] of Object.entries(workflows)) {
|
||||
server.register(name, config);
|
||||
}
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type {
|
||||
Workflow,
|
||||
WorkflowEvent,
|
||||
WorkflowEventData,
|
||||
} from "@llamaindex/workflow-core";
|
||||
|
||||
export type {
|
||||
CancelQuery,
|
||||
CancelResponse,
|
||||
ErrorResponse,
|
||||
EventSchema,
|
||||
HandlerInfo,
|
||||
HandlerStatus,
|
||||
HealthResponse,
|
||||
SendEventRequest,
|
||||
SendEventResponse,
|
||||
StreamEvent,
|
||||
StreamQuery,
|
||||
WorkflowEventsResponse,
|
||||
WorkflowRunAsyncResponse,
|
||||
WorkflowRunRequest,
|
||||
WorkflowRunResponse,
|
||||
WorkflowSchemaResponse,
|
||||
} from "./schemas";
|
||||
|
||||
export type WorkflowEventWithSchema<T = unknown> = WorkflowEvent<T> & {
|
||||
readonly schema?: unknown;
|
||||
};
|
||||
|
||||
export interface WorkflowConfig<TStartData = unknown, TStopData = unknown> {
|
||||
workflow: Workflow;
|
||||
startEvent: WorkflowEvent<TStartData>;
|
||||
stopEvent: WorkflowEvent<TStopData>;
|
||||
/**
|
||||
* Additional events that can be sent to this workflow.
|
||||
* These are used for event schema generation and sending events to running workflows.
|
||||
*/
|
||||
additionalEvents?: WorkflowEvent<unknown>[];
|
||||
}
|
||||
|
||||
export interface WorkflowServerOptions {
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
export interface RegisteredWorkflow<TStartData = unknown, TStopData = unknown>
|
||||
extends WorkflowConfig<TStartData, TStopData> {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export type ExtractEventData<T> = T extends WorkflowEventData<infer D>
|
||||
? D
|
||||
: never;
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./lib",
|
||||
"tsBuildInfoFile": "./lib/.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [
|
||||
{
|
||||
"path": "../core/tsconfig.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tsconfigPaths({
|
||||
projects: ["./tsconfig.json"],
|
||||
}),
|
||||
],
|
||||
test: {
|
||||
include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
|
||||
exclude: ["**/dist/**", "**/lib/**", "**/node_modules/**"],
|
||||
},
|
||||
});
|
||||
Generated
+461
-12
@@ -276,6 +276,31 @@ importers:
|
||||
specifier: ^4.20.3
|
||||
version: 4.20.4
|
||||
|
||||
demo/server:
|
||||
dependencies:
|
||||
'@fastify/swagger':
|
||||
specifier: ^9.4.2
|
||||
version: 9.6.1
|
||||
'@fastify/swagger-ui':
|
||||
specifier: ^5.2.3
|
||||
version: 5.2.3
|
||||
'@llamaindex/workflow-core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/core
|
||||
'@llamaindex/workflow-server':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/server
|
||||
fastify:
|
||||
specifier: ^5.6.2
|
||||
version: 5.6.2
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.0.4
|
||||
version: 24.0.4
|
||||
tsx:
|
||||
specifier: ^4.20.3
|
||||
version: 4.20.4
|
||||
|
||||
demo/trace-events:
|
||||
dependencies:
|
||||
'@llamaindex/workflow-core':
|
||||
@@ -536,6 +561,34 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.1.9(@edge-runtime/vm@5.0.0)(@types/node@22.15.33)(@vitest/ui@2.1.9)(happy-dom@18.0.1)(lightningcss@1.30.1)(terser@5.43.1)
|
||||
|
||||
packages/server:
|
||||
dependencies:
|
||||
nanoid:
|
||||
specifier: ^5.1.5
|
||||
version: 5.1.6
|
||||
zod:
|
||||
specifier: ^3.25.76
|
||||
version: 3.25.76
|
||||
zod-to-json-schema:
|
||||
specifier: ^3.25.0
|
||||
version: 3.25.0(zod@3.25.76)
|
||||
devDependencies:
|
||||
'@llamaindex/workflow-core':
|
||||
specifier: workspace:*
|
||||
version: link:../core
|
||||
'@types/node':
|
||||
specifier: ^24.0.4
|
||||
version: 24.0.4
|
||||
bunchee:
|
||||
specifier: ^6.5.4
|
||||
version: 6.5.4(typescript@5.9.2)
|
||||
fastify:
|
||||
specifier: ^5.6.2
|
||||
version: 5.6.2
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/debug@4.1.12)(@types/node@24.0.4)(happy-dom@18.0.1)(jiti@2.5.1)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.4)(yaml@2.8.0)
|
||||
|
||||
packages/viz:
|
||||
dependencies:
|
||||
'@babel/parser':
|
||||
@@ -1458,6 +1511,12 @@ packages:
|
||||
'@expressive-code/plugin-text-markers@0.41.3':
|
||||
resolution: {integrity: sha512-SN8tkIzDpA0HLAscEYD2IVrfLiid6qEdE9QLlGVSxO1KEw7qYvjpbNBQjUjMr5/jvTJ7ys6zysU2vLPHE0sb2g==}
|
||||
|
||||
'@fastify/accept-negotiator@2.0.1':
|
||||
resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==}
|
||||
|
||||
'@fastify/ajv-compiler@4.0.5':
|
||||
resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==}
|
||||
|
||||
'@fastify/busboy@2.1.1':
|
||||
resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -1465,6 +1524,33 @@ packages:
|
||||
'@fastify/deepmerge@1.3.0':
|
||||
resolution: {integrity: sha512-J8TOSBq3SoZbDhM9+R/u77hP93gz/rajSA+K2kGyijPpORPWUXHUpTaleoj+92As0S9uPRP7Oi8IqMf0u+ro6A==}
|
||||
|
||||
'@fastify/error@4.2.0':
|
||||
resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==}
|
||||
|
||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||
resolution: {integrity: sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==}
|
||||
|
||||
'@fastify/forwarded@3.0.1':
|
||||
resolution: {integrity: sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==}
|
||||
|
||||
'@fastify/merge-json-schemas@0.2.1':
|
||||
resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==}
|
||||
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||
|
||||
'@fastify/send@4.1.0':
|
||||
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
||||
|
||||
'@fastify/static@8.3.0':
|
||||
resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==}
|
||||
|
||||
'@fastify/swagger-ui@5.2.3':
|
||||
resolution: {integrity: sha512-e7ivEJi9EpFcxTONqICx4llbpB2jmlI+LI1NQ/mR7QGQnyDOqZybPK572zJtcdHZW4YyYTBHcP3a03f1pOh0SA==}
|
||||
|
||||
'@fastify/swagger@9.6.1':
|
||||
resolution: {integrity: sha512-fKlpJqFMWoi4H3EdUkDaMteEYRCfQMEkK0HJJ0eaf4aRlKd8cbq0pVkOfXDXmtvMTXYcnx3E+l023eFDBsA1HA==}
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
|
||||
|
||||
@@ -1947,6 +2033,10 @@ packages:
|
||||
'@js-sdsl/ordered-map@4.4.2':
|
||||
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
|
||||
|
||||
'@lukeed/ms@2.0.2':
|
||||
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
'@manypkg/find-root@1.1.0':
|
||||
resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==}
|
||||
|
||||
@@ -2297,6 +2387,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@pinojs/redact@0.4.0':
|
||||
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
|
||||
|
||||
'@polka/url@1.0.0-next.29':
|
||||
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
|
||||
|
||||
@@ -3580,6 +3673,9 @@ packages:
|
||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||
engines: {node: '>=6.5'}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
accepts@2.0.0:
|
||||
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -3616,9 +3712,20 @@ packages:
|
||||
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
|
||||
engines: {node: '>= 8.0.0'}
|
||||
|
||||
ajv-formats@3.0.1:
|
||||
resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==}
|
||||
peerDependencies:
|
||||
ajv: ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
ajv:
|
||||
optional: true
|
||||
|
||||
ajv@6.12.6:
|
||||
resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==}
|
||||
|
||||
ajv@8.17.1:
|
||||
resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==}
|
||||
|
||||
ansi-align@3.0.1:
|
||||
resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==}
|
||||
|
||||
@@ -3800,6 +3907,10 @@ packages:
|
||||
asynckit@0.4.0:
|
||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
autolinker@3.16.2:
|
||||
resolution: {integrity: sha512-JiYl7j2Z19F9NdTmirENSUUIIL/9MytEWtmzhfmsKPCp9E+G35Y0UNCMoM9tFigxT59qSc8Ml2dlZXOCVTYwuA==}
|
||||
|
||||
@@ -3807,6 +3918,9 @@ packages:
|
||||
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
avvio@9.1.0:
|
||||
resolution: {integrity: sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw==}
|
||||
|
||||
aws-sign2@0.7.0:
|
||||
resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==}
|
||||
|
||||
@@ -4224,6 +4338,10 @@ packages:
|
||||
resolution: {integrity: sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
content-disposition@0.5.4:
|
||||
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
content-disposition@1.0.0:
|
||||
resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4895,6 +5013,9 @@ packages:
|
||||
resolution: {integrity: sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==}
|
||||
engines: {'0': node >=0.6.0}
|
||||
|
||||
fast-decode-uri-component@1.0.1:
|
||||
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -4912,9 +5033,24 @@ packages:
|
||||
fast-json-stable-stringify@2.1.0:
|
||||
resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==}
|
||||
|
||||
fast-json-stringify@6.1.1:
|
||||
resolution: {integrity: sha512-DbgptncYEXZqDUOEl4krff4mUiVrTZZVI7BBrQR/T3BqMj/eM1flTC1Uk2uUoLcWCxjT95xKulV/Lc6hhOZsBQ==}
|
||||
|
||||
fast-levenshtein@2.0.6:
|
||||
resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==}
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
fastify-plugin@5.1.0:
|
||||
resolution: {integrity: sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==}
|
||||
|
||||
fastify@5.6.2:
|
||||
resolution: {integrity: sha512-dPugdGnsvYkBlENLhCgX8yhyGCsCPrpA8lFWbTNU428l+YOnLgYHR69hzV8HWPC79n536EqzqQtvhtdaCE0dKg==}
|
||||
|
||||
fastq@1.19.1:
|
||||
resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==}
|
||||
|
||||
@@ -4961,6 +5097,10 @@ packages:
|
||||
resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
find-my-way@9.3.0:
|
||||
resolution: {integrity: sha512-eRoFWQw+Yv2tuYlK2pjFS2jGXSxSppAs3hSQjfxVKxM5amECzIgYYc1FEI8ZmhSh/Ig+FrKEz43NLRKJjYCZVg==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
find-up@4.1.0:
|
||||
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5534,6 +5674,10 @@ packages:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
ipaddr.js@2.2.0:
|
||||
resolution: {integrity: sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
iron-webcrypto@1.2.1:
|
||||
resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==}
|
||||
|
||||
@@ -5803,9 +5947,19 @@ packages:
|
||||
json-parse-even-better-errors@2.3.1:
|
||||
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
|
||||
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
|
||||
|
||||
json-schema-resolver@3.0.0:
|
||||
resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
json-schema-traverse@0.4.1:
|
||||
resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==}
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json-schema@0.4.0:
|
||||
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
|
||||
|
||||
@@ -5880,6 +6034,9 @@ packages:
|
||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
light-my-request@6.6.0:
|
||||
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -6441,6 +6598,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.6:
|
||||
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@2.0.0:
|
||||
resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==}
|
||||
|
||||
@@ -6604,6 +6766,10 @@ packages:
|
||||
ohash@2.0.11:
|
||||
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
on-finished@2.4.1:
|
||||
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -6661,6 +6827,9 @@ packages:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
optionator@0.9.4:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -6873,6 +7042,16 @@ packages:
|
||||
resolution: {integrity: sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==}
|
||||
|
||||
pino-std-serializers@7.0.0:
|
||||
resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==}
|
||||
|
||||
pino@10.1.0:
|
||||
resolution: {integrity: sha512-0zZC2ygfdqvqK8zJIr1e+wT1T/L+LF6qvqvbzEQ6tiMAoTqEVK9a1K3YRu8HEUvGEvNqZyPJTtb2sNIoTkB83w==}
|
||||
hasBin: true
|
||||
|
||||
pkce-challenge@5.0.0:
|
||||
resolution: {integrity: sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
@@ -6937,6 +7116,12 @@ packages:
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
process-warning@4.0.1:
|
||||
resolution: {integrity: sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==}
|
||||
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
progress@1.1.8:
|
||||
resolution: {integrity: sha512-UdA8mJ4weIkUBO224tIarHzuHs4HuYiJvsuGT7j/SPQiUJVjYvNDBIPa0hAorduOfjGohB/qHWRa/lrrWX/mXw==}
|
||||
engines: {node: '>=0.4.0'}
|
||||
@@ -7013,6 +7198,9 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
radix3@1.1.2:
|
||||
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
|
||||
|
||||
@@ -7103,6 +7291,10 @@ packages:
|
||||
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
|
||||
engines: {node: '>= 14.18.0'}
|
||||
|
||||
real-require@0.2.0:
|
||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||
engines: {node: '>= 12.13.0'}
|
||||
|
||||
recma-build-jsx@1.0.0:
|
||||
resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==}
|
||||
|
||||
@@ -7197,6 +7389,10 @@ packages:
|
||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-in-the-middle@7.5.2:
|
||||
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@@ -7232,6 +7428,10 @@ packages:
|
||||
restructure@3.0.2:
|
||||
resolution: {integrity: sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==}
|
||||
|
||||
ret@0.5.0:
|
||||
resolution: {integrity: sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
retext-latin@4.0.0:
|
||||
resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==}
|
||||
|
||||
@@ -7341,6 +7541,13 @@ packages:
|
||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
safe-regex2@5.0.0:
|
||||
resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==}
|
||||
|
||||
safe-stable-stringify@2.5.0:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
@@ -7357,6 +7564,9 @@ packages:
|
||||
resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
secure-json-parse@4.1.0:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
|
||||
semver@6.3.1:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
@@ -7380,6 +7590,9 @@ packages:
|
||||
resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
set-cookie-parser@2.7.2:
|
||||
resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -7503,6 +7716,9 @@ packages:
|
||||
resolution: {integrity: sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==}
|
||||
engines: {node: '>= 10.0.0', npm: '>= 3.0.0'}
|
||||
|
||||
sonic-boom@4.2.0:
|
||||
resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==}
|
||||
|
||||
source-map-js@1.2.1:
|
||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -7524,6 +7740,10 @@ packages:
|
||||
spawndamnit@3.0.1:
|
||||
resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==}
|
||||
|
||||
split2@4.2.0:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
@@ -7760,6 +7980,9 @@ packages:
|
||||
text-decoder@1.2.3:
|
||||
resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==}
|
||||
|
||||
thread-stream@3.1.0:
|
||||
resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==}
|
||||
|
||||
throttleit@1.0.1:
|
||||
resolution: {integrity: sha512-vDZpf9Chs9mAdfY046mcPt8fg5QSZr37hEH4TXYBnDF+izxgrbRGUAAaBvIk/fJm9aOFCGFd1EsNg5AZCbnQCQ==}
|
||||
|
||||
@@ -7818,6 +8041,10 @@ packages:
|
||||
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
|
||||
engines: {node: '>=8.0'}
|
||||
|
||||
toad-cache@3.7.0:
|
||||
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
toidentifier@1.0.1:
|
||||
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||
engines: {node: '>=0.6'}
|
||||
@@ -8648,6 +8875,11 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.24.1
|
||||
|
||||
zod-to-json-schema@3.25.0:
|
||||
resolution: {integrity: sha512-HvWtU2UG41LALjajJrML6uQejQhNJx+JBO9IflpSja4R03iNWfKXrj6W2h7ljuLyc1nKS+9yDyL/9tD1U/yBnQ==}
|
||||
peerDependencies:
|
||||
zod: ^3.25 || ^4
|
||||
|
||||
zod-to-ts@1.2.0:
|
||||
resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==}
|
||||
peerDependencies:
|
||||
@@ -9473,10 +9705,70 @@ snapshots:
|
||||
dependencies:
|
||||
'@expressive-code/core': 0.41.3
|
||||
|
||||
'@fastify/accept-negotiator@2.0.1': {}
|
||||
|
||||
'@fastify/ajv-compiler@4.0.5':
|
||||
dependencies:
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
fast-uri: 3.1.0
|
||||
|
||||
'@fastify/busboy@2.1.1': {}
|
||||
|
||||
'@fastify/deepmerge@1.3.0': {}
|
||||
|
||||
'@fastify/error@4.2.0': {}
|
||||
|
||||
'@fastify/fast-json-stringify-compiler@5.0.3':
|
||||
dependencies:
|
||||
fast-json-stringify: 6.1.1
|
||||
|
||||
'@fastify/forwarded@3.0.1': {}
|
||||
|
||||
'@fastify/merge-json-schemas@0.2.1':
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
dependencies:
|
||||
'@fastify/forwarded': 3.0.1
|
||||
ipaddr.js: 2.2.0
|
||||
|
||||
'@fastify/send@4.1.0':
|
||||
dependencies:
|
||||
'@lukeed/ms': 2.0.2
|
||||
escape-html: 1.0.3
|
||||
fast-decode-uri-component: 1.0.1
|
||||
http-errors: 2.0.0
|
||||
mime: 3.0.0
|
||||
|
||||
'@fastify/static@8.3.0':
|
||||
dependencies:
|
||||
'@fastify/accept-negotiator': 2.0.1
|
||||
'@fastify/send': 4.1.0
|
||||
content-disposition: 0.5.4
|
||||
fastify-plugin: 5.1.0
|
||||
fastq: 1.19.1
|
||||
glob: 11.0.3
|
||||
|
||||
'@fastify/swagger-ui@5.2.3':
|
||||
dependencies:
|
||||
'@fastify/static': 8.3.0
|
||||
fastify-plugin: 5.1.0
|
||||
openapi-types: 12.1.3
|
||||
rfdc: 1.4.1
|
||||
yaml: 2.8.0
|
||||
|
||||
'@fastify/swagger@9.6.1':
|
||||
dependencies:
|
||||
fastify-plugin: 5.1.0
|
||||
json-schema-resolver: 3.0.0
|
||||
openapi-types: 12.1.3
|
||||
rfdc: 1.4.1
|
||||
yaml: 2.8.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@floating-ui/core@1.7.3':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.10
|
||||
@@ -9876,7 +10168,7 @@ snapshots:
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.13':
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
|
||||
'@jridgewell/gen-mapping@0.3.8':
|
||||
@@ -9896,8 +10188,8 @@ snapshots:
|
||||
|
||||
'@jridgewell/source-map@0.3.6':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.8
|
||||
'@jridgewell/trace-mapping': 0.3.25
|
||||
'@jridgewell/gen-mapping': 0.3.13
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
optional: true
|
||||
|
||||
'@jridgewell/sourcemap-codec@1.5.0': {}
|
||||
@@ -9912,7 +10204,7 @@ snapshots:
|
||||
'@jridgewell/trace-mapping@0.3.31':
|
||||
dependencies:
|
||||
'@jridgewell/resolve-uri': 3.1.2
|
||||
'@jridgewell/sourcemap-codec': 1.5.0
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
'@jridgewell/trace-mapping@0.3.9':
|
||||
dependencies:
|
||||
@@ -9921,6 +10213,8 @@ snapshots:
|
||||
|
||||
'@js-sdsl/ordered-map@4.4.2': {}
|
||||
|
||||
'@lukeed/ms@2.0.2': {}
|
||||
|
||||
'@manypkg/find-root@1.1.0':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.27.6
|
||||
@@ -10332,6 +10626,8 @@ snapshots:
|
||||
'@pagefind/windows-x64@1.4.0':
|
||||
optional: true
|
||||
|
||||
'@pinojs/redact@0.4.0': {}
|
||||
|
||||
'@polka/url@1.0.0-next.29': {}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
@@ -10810,10 +11106,10 @@ snapshots:
|
||||
'@rollup/pluginutils': 5.2.0(rollup@4.44.0)
|
||||
commondir: 1.0.1
|
||||
estree-walker: 2.0.2
|
||||
fdir: 6.4.6(picomatch@4.0.2)
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
is-reference: 1.2.1
|
||||
magic-string: 0.30.17
|
||||
picomatch: 4.0.2
|
||||
magic-string: 0.30.19
|
||||
picomatch: 4.0.3
|
||||
optionalDependencies:
|
||||
rollup: 4.44.0
|
||||
|
||||
@@ -10836,7 +11132,7 @@ snapshots:
|
||||
'@rollup/plugin-replace@6.0.2(rollup@4.44.0)':
|
||||
dependencies:
|
||||
'@rollup/pluginutils': 5.2.0(rollup@4.44.0)
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
optionalDependencies:
|
||||
rollup: 4.44.0
|
||||
|
||||
@@ -11587,6 +11883,8 @@ snapshots:
|
||||
dependencies:
|
||||
event-target-shim: 5.0.1
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
accepts@2.0.0:
|
||||
dependencies:
|
||||
mime-types: 3.0.1
|
||||
@@ -11612,6 +11910,10 @@ snapshots:
|
||||
dependencies:
|
||||
humanize-ms: 1.2.1
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.17.1):
|
||||
optionalDependencies:
|
||||
ajv: 8.17.1
|
||||
|
||||
ajv@6.12.6:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
@@ -11619,6 +11921,13 @@ snapshots:
|
||||
json-schema-traverse: 0.4.1
|
||||
uri-js: 4.4.1
|
||||
|
||||
ajv@8.17.1:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.0
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-align@3.0.1:
|
||||
dependencies:
|
||||
string-width: 4.2.3
|
||||
@@ -11900,6 +12209,8 @@ snapshots:
|
||||
|
||||
asynckit@0.4.0: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
autolinker@3.16.2:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -11908,6 +12219,11 @@ snapshots:
|
||||
dependencies:
|
||||
possible-typed-array-names: 1.1.0
|
||||
|
||||
avvio@9.1.0:
|
||||
dependencies:
|
||||
'@fastify/error': 4.2.0
|
||||
fastq: 1.19.1
|
||||
|
||||
aws-sign2@0.7.0: {}
|
||||
|
||||
aws4@1.13.2: {}
|
||||
@@ -12077,9 +12393,9 @@ snapshots:
|
||||
'@swc/helpers': 0.5.17
|
||||
clean-css: 5.3.3
|
||||
fast-glob: 3.3.3
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
ora: 8.2.0
|
||||
picomatch: 4.0.2
|
||||
picomatch: 4.0.3
|
||||
pretty-bytes: 5.6.0
|
||||
rollup: 4.44.0
|
||||
rollup-plugin-dts: 6.2.1(rollup@4.44.0)(typescript@5.9.2)
|
||||
@@ -12340,6 +12656,10 @@ snapshots:
|
||||
|
||||
content-disposition@0.5.2: {}
|
||||
|
||||
content-disposition@0.5.4:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
content-disposition@1.0.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
@@ -13198,6 +13518,8 @@ snapshots:
|
||||
|
||||
extsprintf@1.3.0: {}
|
||||
|
||||
fast-decode-uri-component@1.0.1: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
@@ -13220,8 +13542,43 @@ snapshots:
|
||||
|
||||
fast-json-stable-stringify@2.1.0: {}
|
||||
|
||||
fast-json-stringify@6.1.1:
|
||||
dependencies:
|
||||
'@fastify/merge-json-schemas': 0.2.1
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
fast-uri: 3.1.0
|
||||
json-schema-ref-resolver: 3.0.0
|
||||
rfdc: 1.4.1
|
||||
|
||||
fast-levenshtein@2.0.6: {}
|
||||
|
||||
fast-querystring@1.1.2:
|
||||
dependencies:
|
||||
fast-decode-uri-component: 1.0.1
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
|
||||
fastify@5.6.2:
|
||||
dependencies:
|
||||
'@fastify/ajv-compiler': 4.0.5
|
||||
'@fastify/error': 4.2.0
|
||||
'@fastify/fast-json-stringify-compiler': 5.0.3
|
||||
'@fastify/proxy-addr': 5.1.0
|
||||
abstract-logging: 2.0.1
|
||||
avvio: 9.1.0
|
||||
fast-json-stringify: 6.1.1
|
||||
find-my-way: 9.3.0
|
||||
light-my-request: 6.6.0
|
||||
pino: 10.1.0
|
||||
process-warning: 5.0.0
|
||||
rfdc: 1.4.1
|
||||
secure-json-parse: 4.1.0
|
||||
semver: 7.7.2
|
||||
toad-cache: 3.7.0
|
||||
|
||||
fastq@1.19.1:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -13268,6 +13625,12 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
find-my-way@9.3.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-querystring: 1.1.2
|
||||
safe-regex2: 5.0.0
|
||||
|
||||
find-up@4.1.0:
|
||||
dependencies:
|
||||
locate-path: 5.0.0
|
||||
@@ -14003,6 +14366,8 @@ snapshots:
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
ipaddr.js@2.2.0: {}
|
||||
|
||||
iron-webcrypto@1.2.1: {}
|
||||
|
||||
is-alphabetical@2.0.1: {}
|
||||
@@ -14239,8 +14604,22 @@ snapshots:
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
|
||||
json-schema-ref-resolver@3.0.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
json-schema-resolver@3.0.0:
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
fast-uri: 3.1.0
|
||||
rfdc: 1.4.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
json-schema-traverse@0.4.1: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema@0.4.0: {}
|
||||
|
||||
json-stable-stringify-without-jsonify@1.0.1: {}
|
||||
@@ -14312,6 +14691,12 @@ snapshots:
|
||||
prelude-ls: 1.2.1
|
||||
type-check: 0.4.0
|
||||
|
||||
light-my-request@6.6.0:
|
||||
dependencies:
|
||||
cookie: 1.0.2
|
||||
process-warning: 4.0.1
|
||||
set-cookie-parser: 2.7.2
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
optional: true
|
||||
|
||||
@@ -15167,6 +15552,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.6: {}
|
||||
|
||||
napi-build-utils@2.0.0: {}
|
||||
|
||||
napi-postinstall@0.3.3: {}
|
||||
@@ -15318,6 +15705,8 @@ snapshots:
|
||||
|
||||
ohash@2.0.11: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
dependencies:
|
||||
ee-first: 1.1.1
|
||||
@@ -15367,6 +15756,8 @@ snapshots:
|
||||
ws: 8.18.3
|
||||
zod: 3.25.76
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
deep-is: 0.1.4
|
||||
@@ -15592,6 +15983,26 @@ snapshots:
|
||||
|
||||
pinkie@2.0.4: {}
|
||||
|
||||
pino-abstract-transport@2.0.0:
|
||||
dependencies:
|
||||
split2: 4.2.0
|
||||
|
||||
pino-std-serializers@7.0.0: {}
|
||||
|
||||
pino@10.1.0:
|
||||
dependencies:
|
||||
'@pinojs/redact': 0.4.0
|
||||
atomic-sleep: 1.0.0
|
||||
on-exit-leak-free: 2.1.2
|
||||
pino-abstract-transport: 2.0.0
|
||||
pino-std-serializers: 7.0.0
|
||||
process-warning: 5.0.0
|
||||
quick-format-unescaped: 4.0.4
|
||||
real-require: 0.2.0
|
||||
safe-stable-stringify: 2.5.0
|
||||
sonic-boom: 4.2.0
|
||||
thread-stream: 3.1.0
|
||||
|
||||
pkce-challenge@5.0.0: {}
|
||||
|
||||
pkg-types@1.3.1:
|
||||
@@ -15662,6 +16073,10 @@ snapshots:
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
process-warning@4.0.1: {}
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
progress@1.1.8: {}
|
||||
|
||||
progress@2.0.3: {}
|
||||
@@ -15773,6 +16188,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
radix3@1.1.2: {}
|
||||
|
||||
range-parser@1.2.0: {}
|
||||
@@ -15865,6 +16282,8 @@ snapshots:
|
||||
|
||||
readdirp@4.1.2: {}
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
recma-build-jsx@1.0.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
@@ -16066,6 +16485,8 @@ snapshots:
|
||||
|
||||
require-directory@2.1.1: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
require-in-the-middle@7.5.2:
|
||||
dependencies:
|
||||
debug: 4.4.1
|
||||
@@ -16104,6 +16525,8 @@ snapshots:
|
||||
|
||||
restructure@3.0.2: {}
|
||||
|
||||
ret@0.5.0: {}
|
||||
|
||||
retext-latin@4.0.0:
|
||||
dependencies:
|
||||
'@types/nlcst': 2.0.3
|
||||
@@ -16200,7 +16623,7 @@ snapshots:
|
||||
|
||||
rollup-plugin-dts@6.2.1(rollup@4.44.0)(typescript@5.9.2):
|
||||
dependencies:
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
rollup: 4.44.0
|
||||
typescript: 5.9.2
|
||||
optionalDependencies:
|
||||
@@ -16217,7 +16640,7 @@ snapshots:
|
||||
|
||||
rollup-preserve-directives@1.1.3(rollup@4.44.0):
|
||||
dependencies:
|
||||
magic-string: 0.30.17
|
||||
magic-string: 0.30.19
|
||||
rollup: 4.44.0
|
||||
|
||||
rollup@4.44.0:
|
||||
@@ -16291,6 +16714,12 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
is-regex: 1.2.1
|
||||
|
||||
safe-regex2@5.0.0:
|
||||
dependencies:
|
||||
ret: 0.5.0
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
sax@1.4.1: {}
|
||||
@@ -16306,6 +16735,8 @@ snapshots:
|
||||
extend-shallow: 2.0.1
|
||||
kind-of: 6.0.3
|
||||
|
||||
secure-json-parse@4.1.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.7.2: {}
|
||||
@@ -16347,6 +16778,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
|
||||
set-function-length@1.2.2:
|
||||
dependencies:
|
||||
define-data-property: 1.1.4
|
||||
@@ -16559,6 +16992,10 @@ snapshots:
|
||||
ip-address: 10.0.1
|
||||
smart-buffer: 4.2.0
|
||||
|
||||
sonic-boom@4.2.0:
|
||||
dependencies:
|
||||
atomic-sleep: 1.0.0
|
||||
|
||||
source-map-js@1.2.1: {}
|
||||
|
||||
source-map-support@0.5.21:
|
||||
@@ -16578,6 +17015,8 @@ snapshots:
|
||||
cross-spawn: 7.0.6
|
||||
signal-exit: 4.1.0
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
sshpk@1.18.0:
|
||||
@@ -16876,6 +17315,10 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- react-native-b4a
|
||||
|
||||
thread-stream@3.1.0:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
|
||||
throttleit@1.0.1: {}
|
||||
|
||||
through2@3.0.2:
|
||||
@@ -16923,6 +17366,8 @@ snapshots:
|
||||
dependencies:
|
||||
is-number: 7.0.0
|
||||
|
||||
toad-cache@3.7.0: {}
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
totalist@3.0.1: {}
|
||||
@@ -17738,6 +18183,10 @@ snapshots:
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.0(zod@3.25.76):
|
||||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-ts@1.2.0(typescript@5.9.2)(zod@3.25.76):
|
||||
dependencies:
|
||||
typescript: 5.9.2
|
||||
|
||||
Reference in New Issue
Block a user