feat: add Fastify-based workflow server (#192)

This commit is contained in:
Huu Le
2025-12-02 12:56:07 +07:00
committed by GitHub
parent 79c0e46069
commit 266c2df2aa
30 changed files with 3863 additions and 13 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@llamaindex/workflow-server": patch
---
Add a workflow server for serving workflows as REST APIs
+104
View File
@@ -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,
});
```
+21
View File
@@ -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"
}
}
+116
View File
@@ -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);
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./",
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022"
},
"include": ["./*.ts", "./workflows/**/*.ts"]
}
+54
View File
@@ -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 });
});
+44
View File
@@ -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)`);
});
+22
View File
@@ -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.`);
});
+19
View File
@@ -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 -1
View File
@@ -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>({
+149
View File
@@ -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" }
```
+7
View File
@@ -0,0 +1,7 @@
{
"root": false,
"extends": "//",
"files": {
"includes": ["src/**"]
}
}
+65
View File
@@ -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"
}
}
+20
View File
@@ -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";
}
}
+251
View File
@@ -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();
}
+22
View File
@@ -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";
+239
View File
@@ -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 });
}
},
});
}
+133
View File
@@ -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,
};
},
});
}
+18
View File
@@ -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 }),
});
}
+5
View File
@@ -0,0 +1,5 @@
export { registerEventRoutes } from "./events";
export { registerHandlerRoutes } from "./handlers";
export { registerHealthRoutes } from "./health";
export { registerStreamRoutes } from "./stream";
export { registerWorkflowRoutes } from "./workflows";
+304
View File
@@ -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);
});
});
+209
View File
@@ -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;
},
});
}
+122
View File
@@ -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);
},
});
}
+151
View File
@@ -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>;
+978
View File
@@ -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);
});
});
});
+253
View File
@@ -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;
}
+52
View File
@@ -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;
+13
View File
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./lib",
"tsBuildInfoFile": "./lib/.tsbuildinfo"
},
"include": ["./src"],
"references": [
{
"path": "../core/tsconfig.json"
}
]
}
+14
View File
@@ -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/**"],
},
});
+461 -12
View File
@@ -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