A fork of Spiceflow to be used at Fabriq
@fabriq/spiceflow
Fork of Spiceflow 1.9.1 to be used at Fabriq. It has been stripped down to only include what you need now or may meed later, and to primarily support Deno.
Original upstream README below.
Spiceflow is a lightweight, type-safe API framework for building web services using modern web standards.
Features
- Type safe schema based validation via Zod
- Can easily generate OpenAPI spec based on your routes
- Native support for Fern to generate docs and SDKs (see example docs here)
- Support for Model Context Protocol to easily wire your app with LLMs
- Type safe RPC client generation
- Simple and intuitive API
- Uses web standards for requests and responses
- Supports async generators for streaming via server sent events
- Modular design with
.use()
for mounting sub-apps - Base path support
Installation
npm install spiceflow zod
Basic Usage
Objects returned from route handlers are automatically serialized to JSON
import { Spiceflow } from "spiceflow"; const app = new Spiceflow() .get("/hello", () => "Hello, World!") .post("/echo", async ({ request }) => { const body = await request.json(); return { echo: body }; }); app.listen(3000);
Never declare app and add routes separately, that way you lose the type safety. Instead always append routes with .post and .get in a single expression.
// This is an example of what NOT to do when using Spiceflow import { Spiceflow } from "spiceflow"; // DO NOT declare the app separately and add routes later const app = new Spiceflow(); // Do NOT do this! Adding routes separately like this will lose type safety app.get("/hello", () => "Hello, World!"); app.post("/echo", async ({ request }) => { const body = await request.json(); return body; });
Comparisons
Elysia
This project was born as a fork of Elysia with several changes:
- Use Zod instead of Typebox
- Do not compile user code with
aot
andeval
, Elysia is very difficult to contribue to because the app is generated by compiling the user routes withnew Function()
, which also causes several bugs - Better async generator support by using SSE
Hono
This project shares many inspirations with Hono with many differences
- First class OpenAPI support, you don't need to change anything to produce an
OpenAPI spec, just add the
openapi
plugin to automaitcally export your openapi schema on/openapi
- Much simpler framework, everything is done with native
Request
andResponse
objects instead of framework specific utilities - Support for async generators
- Adding schemas to your routes is easier and does not require using
validator
functions, which slow down TypeScript inference - The generated RPC client has much faster type inference, intellisense in VSCode appears in milliseconds instead of seconds
- Spiceflow uses whatwg Request and Response instead of custom utilities like
c.text
andc.req
Requests and Responses
POST Request with Body Schema
import { z } from "zod"; import { Spiceflow } from "spiceflow"; new Spiceflow().post( "/users", async ({ request }) => { const body = await request.json(); // here body has type { name: string, email: string } return `Created user: ${body.name}`; }, { body: z.object({ name: z.string(), email: z.string().email(), }), }, );
Notice that to get the body of the request, you need to call
request.json()
to parse the body as JSON. Spiceflow does not parse the Body automatically, there is no body field in the Spiceflow route argument, instead you call eitherrequest.json()
orrequest.formData()
to get the body and validate it at the same time. This works by wrapping the request in aSpiceflowRequest
instance, which has ajson()
andformData()
method that parse the body and validate it. The returned data will have the correct schema type instead ofany
.
Response Schema
import { z } from "zod"; import { Spiceflow } from "spiceflow"; new Spiceflow().get( "/users/:id", ({ request, params }) => { const typedJson = await request.json(); // this body will have the correct type return { id: Number(params.id), name: typedJson.name }; }, { body: z.object({ name: z.string(), }), response: z.object({ id: z.number(), name: z.string(), }), params: z.object({ id: z.string(), }), }, );
Generate RPC Client
import { createSpiceflowClient } from "spiceflow/client"; import { Spiceflow } from "spiceflow"; import { z } from "zod"; // Define the app with multiple routes and features const app = new Spiceflow() .get("/hello/:id", ({ params }) => `Hello, ${params.id}!`) .post( "/users", async ({ request }) => { const body = await request.json(); // here body has type { name?: string, email?: string } return `Created user: ${body.name}`; }, { body: z.object({ name: z.string().optional(), email: z.string().email().optional(), }), }, ) .get("/stream", async function* () { yield "Start"; await new Promise((resolve) => setTimeout(resolve, 1000)); yield "Middle"; await new Promise((resolve) => setTimeout(resolve, 1000)); yield "End"; }); // Create the client const client = createSpiceflowClient<typeof app>("http://localhost:3000"); // Example usage of the client async function exampleUsage() { // GET request const { data: helloData, error: helloError } = await client .hello({ id: "World" }) .get(); if (helloError) { console.error("Error fetching hello:", helloError); } else { console.log("Hello response:", helloData); } // POST request const { data: userData, error: userError } = await client.users.post({ name: "John Doe", email: "john.doe@example.com", }); if (userError) { console.error("Error creating user:", userError); } else { console.log("User creation response:", userData); } // Async generator (streaming) request const { data: streamData, error: streamError } = await client.stream.get(); if (streamError) { console.error("Error fetching stream:", streamError); } else { for await (const chunk of streamData) { console.log("Stream chunk:", chunk); } } }
Mounting Sub-Apps
import { Spiceflow } from "spiceflow"; import { z } from "zod"; const mainApp = new Spiceflow() .post( "/users", async ({ request }) => `Created user: ${(await request.json()).name}`, { body: z.object({ name: z.string(), }), }, ) .use(new Spiceflow().get("/", () => "Users list"));
Base Path
import { Spiceflow } from "spiceflow"; const app = new Spiceflow({ basePath: "/api/v1" }); app.get("/hello", () => "Hello"); // Accessible at /api/v1/hello
Async Generators (Streaming)
Async generators will create a server sent event response.
import { Spiceflow } from "spiceflow"; const app = new Spiceflow().get("/sseStream", async function* () { yield { message: "Start" }; await new Promise((resolve) => setTimeout(resolve, 1000)); yield { message: "Middle" }; await new Promise((resolve) => setTimeout(resolve, 1000)); yield { message: "End" }; }); // Server-Sent Events (SSE) format // The server will send events in the following format: // data: {"message":"Start"} // data: {"message":"Middle"} // data: {"message":"End"} // Example response output: // data: {"message":"Start"} // data: {"message":"Middle"} // data: {"message":"End"} // Client usage example with RPC client import { createSpiceflowClient } from "spiceflow/client"; const client = createSpiceflowClient<typeof app>("http://localhost:3000"); async function fetchStream() { const response = await client.sseStream.get(); if (response.error) { console.error("Error fetching stream:", response.error); } else { for await (const chunk of response.data) { console.log("Stream chunk:", chunk); } } } fetchStream();
Error Handling
import { Spiceflow } from "spiceflow"; new Spiceflow().onError(({ error }) => { console.error(error); return new Response("An error occurred", { status: 500 }); });
Middleware
import { Spiceflow } from "spiceflow"; new Spiceflow().use(({ request }) => { console.log(`Received ${request.method} request to ${request.url}`); });
How errors are handled in Spiceflow client
The Spiceflow client provides type-safe error handling by returning either a
data
or error
property. When using the client:
- Thrown errors appear in the
error
field - Response objects can be thrown or returned
- Responses with status codes 200-299 appear in the
data
field - Responses with status codes < 200 or ≥ 300 appear in the
error
field
The example below demonstrates handling different types of responses:
import { Spiceflow } from "spiceflow"; import { createSpiceflowClient } from "spiceflow/client"; const app = new Spiceflow() .get("/error", () => { throw new Error("Something went wrong"); }) .get("/unauthorized", () => { return new Response("Unauthorized access", { status: 401 }); }) .get("/success", () => { throw new Response("Success message", { status: 200 }); return ""; }); const client = createSpiceflowClient<typeof app>("http://localhost:3000"); async function handleErrors() { const errorResponse = await client.error.get(); console.log("Calling error endpoint..."); // Logs: Error occurred: Something went wrong if (errorResponse.error) { console.error("Error occurred:", errorResponse.error); } const unauthorizedResponse = await client.unauthorized.get(); console.log("Calling unauthorized endpoint..."); // Logs: Unauthorized: Unauthorized access (Status: 401) if (unauthorizedResponse.error) { console.error("Unauthorized:", unauthorizedResponse.error); } const successResponse = await client.success.get(); console.log("Calling success endpoint..."); // Logs: Success: Success message if (successResponse.data) { console.log("Success:", successResponse.data); } }
Using the client server side, without network requests
When using the client server-side, you can pass the Spiceflow app instance
directly to createSpiceflowClient()
instead of providing a URL. This allows
you to make "virtual" requests that are handled directly by the app without
making actual network requests. This is useful for testing, generating
documentation, or any other scenario where you want to interact with your API
endpoints programmatically without setting up a server.
Here's an example:
import { Spiceflow } from "spiceflow"; import { createSpiceflowClient } from "spiceflow/client"; import { openapi } from "spiceflow/openapi"; import { writeFile } from "node:fs/promises"; const app = new Spiceflow() .use(openapi({ path: "/openapi" })) .get("/users", () => [ { id: 1, name: "John" }, { id: 2, name: "Jane" }, ]) .post("/users", ({ request }) => request.json()); // Create client by passing app instance directly const client = createSpiceflowClient(app); // Get OpenAPI schema and write to disk const { data } = await client.openapi.get(); await writeFile("openapi.json", JSON.stringify(data, null, 2)); console.log("OpenAPI schema saved to openapi.json");
Modifying Response with Middleware
Middleware in Spiceflow can be used to modify the response before it's sent to the client. This is useful for adding headers, transforming the response body, or performing any other operations on the response.
Here's an example of how to modify the response using middleware:
import { Spiceflow } from "spiceflow"; new Spiceflow() .use(async ({ request }, next) => { const response = await next(); if (response) { // Add a custom header to all responses response.headers.set("X-Powered-By", "Spiceflow"); } return response; }) .get("/example", () => { return { message: "Hello, World!" }; });
Generating OpenAPI Schema
import { openapi } from "spiceflow/openapi"; import { Spiceflow } from "spiceflow"; import { z } from "zod"; const app = new Spiceflow() .use(openapi({ path: "/openapi.json" })) .get("/hello", () => "Hello, World!", { query: z.object({ name: z.string(), age: z.number(), }), response: z.string(), }) .post( "/user", () => { return new Response("Hello, World!"); }, { body: z.object({ name: z.string(), email: z.string().email(), }), }, ); const openapiSchema = await ( await app.handle(new Request("http://localhost:3000/openapi.json")) ).json();
Adding CORS Headers
import { cors } from "spiceflow/cors"; import { Spiceflow } from "spiceflow"; const app = new Spiceflow().use(cors()).get("/hello", () => "Hello, World!");
Proxy requests
import { Spiceflow } from "spiceflow"; import { MiddlewareHandler } from "spiceflow/dist/types"; const app = new Spiceflow(); function createProxyMiddleware({ target, changeOrigin = false, }): MiddlewareHandler { return async (context) => { const { request } = context; const url = new URL(request.url); const proxyReq = new Request( new URL(url.pathname + url.search, target), request, ); if (changeOrigin) { proxyReq.headers.set("origin", new URL(target).origin || ""); } console.log("proxying", proxyReq.url); const res = await fetch(proxyReq); return res; }; } app.use( createProxyMiddleware({ target: "https://api.openai.com", changeOrigin: true, }), ); // or with a basePath app.use( new Spiceflow({ basePath: "/v1/completions" }).use( createProxyMiddleware({ target: "https://api.openai.com", changeOrigin: true, }), ), ); app.listen(3030);
Authorization Middleware
You can handle authorization in a middleware, for example here the code checks if the user is logged in and if not, it throws an error. You can use the state to track request data, in this case the state keeps a reference to the session.
import { z } from "zod"; import { Spiceflow } from "spiceflow"; new Spiceflow() .state("session", null as Session | null) .use(async ({ request: req, state }, next) => { const res = new Response(); const { session } = await getSession({ req, res }); if (!session) { return; } state.session = session; const response = await next(); const cookies = res.headers.getSetCookie(); for (const cookie of cookies) { response.headers.append("Set-Cookie", cookie); } return response; }) .post("/protected", async ({ state }) => { const { session } = state; if (!session) { throw new Error("Not logged in"); } return { ok: true }; });
Non blocking authentication middleware
Sometimes authentication is only required for specific routes, and you don't
want to block public routes while waiting for authentication. You can use
Promise.withResolvers()
to start fetching user data in parallel, allowing
public routes to respond immediately while protected routes wait for
authentication to complete.
The example below demonstrates this pattern - the /public
route responds
instantly while /protected
waits for authentication:
import { Spiceflow } from "spiceflow"; new Spiceflow() .state("userId", Promise.resolve("")) .state("userEmail", Promise.resolve("")) .use(async ({ request, state }, next) => { const sessionKey = request.headers.get("sessionKey"); const userIdPromise = Promise.withResolvers<string>(); const userEmailPromise = Promise.withResolvers<string>(); state.userId = userIdPromise.promise; state.userEmail = userEmailPromise.promise; async function resolveUser() { if (!sessionKey) { userIdPromise.resolve(""); userEmailPromise.resolve(""); return; } const user = await getUser(sessionKey); userIdPromise.resolve(user?.id ?? ""); userEmailPromise.resolve(user?.email ?? ""); } resolveUser(); }) .get("/protected", async ({ state }) => { const userId = await state.userId; if (!userId) throw new Error("Not authenticated"); return { message: "Protected data" }; }) .get("/public", () => ({ message: "Public data" })); async function getUser(sessionKey: string) { await new Promise((resolve) => setTimeout(resolve, 100)); return sessionKey === "valid" ? { id: "123", email: "user@example.com" } : null; }
Model Context Protocol (MCP)
Spiceflow includes a Model Context Protocol (MCP) plugin that exposes your API routes as tools and resources that can be used by AI language models like Claude. The MCP plugin makes it easy to let AI assistants interact with your API endpoints in a controlled way.
When you mount the MCP plugin (default path is /mcp
), it automatically:
- Exposes all your routes as callable tools with proper input validation
- Exposes GET routes without query/path parameters as
resources
- Provides an SSE-based transport for real-time communication
- Handles serialization of requests and responses
This makes it simple to let AI models like Claude discover and call your API endpoints programmatically. Here's an example:
// Import the MCP plugin and client import { mcp } from "spiceflow/mcp"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; import { Spiceflow } from "spiceflow"; import { CallToolResultSchema, ListResourcesResultSchema, ListToolsResultSchema, } from "@modelcontextprotocol/sdk/types.js"; // Create a new app with some example routes const app = new Spiceflow() // Mount the MCP plugin at /mcp (default path) .use(mcp()) // These routes will be available as tools .get("/hello", () => "Hello World") .get("/users/:id", ({ params }) => ({ id: params.id })) .post("/echo", async ({ request }) => { const body = await request.json(); return body; }); // Start the server app.listen(3000); // Example client usage: const transport = new SSEClientTransport(new URL("http://localhost:3000/mcp")); const client = new Client( { name: "example-client", version: "1.0.0" }, { capabilities: {} }, ); await client.connect(transport); // List available tools const tools = await client.request( { method: "tools/list" }, ListToolsResultSchema, ); // Call a tool const result = await client.request( { method: "tools/call", params: { name: "GET /hello", arguments: {}, }, }, CallToolResultSchema, ); // List available resources (only GET /hello is exposed since it has no params) const resources = await client.request( { method: "resources/list" }, ListResourcesResultSchema, );
Generating Fern docs and SDK
Spiceflow has native support for Fern docs and SDK generation using openapi plugin.
The openapi types also have additional types for x-fern
extensions to help you
customize your docs and SDK.
Here is an example script to help you generate an openapi.yml file that you can then use with Fern:
import fs from "fs"; import path from "path"; import yaml from "js-yaml"; import { Spiceflow } from "spiceflow"; import { openapi } from "spiceflow/openapi"; import { createSpiceflowClient } from "spiceflow/client"; const app = new Spiceflow() .use(openapi({ path: "/openapi" })) .get("/hello", () => "Hello World"); async function main() { console.log("Creating Spiceflow client..."); const client = createSpiceflowClient(app); console.log("Fetching OpenAPI spec..."); const { data: openapiJson, error } = await client.openapi.get(); if (error) { console.error("Failed to fetch OpenAPI spec:", error); throw error; } const outputPath = path.resolve("./openapi.yml"); console.log("Writing OpenAPI spec to", outputPath); fs.writeFileSync( outputPath, yaml.dump(openapiJson, { indent: 2, lineWidth: -1, }), ); console.log("Successfully wrote OpenAPI spec"); } main().catch((e) => { console.error("Failed to generate OpenAPI spec:", e); process.exit(1); });
Then follow Fern docs to generate the SDK and docs. You will need to create some Fern yml config files.
You can take a look at the
scripts/example-app.ts
file for an example
app that generates the docs and SDK.
Passing state during handle, passing Cloudflare env bindings
You can use bindings type safely using a .state method and then passing the state in the handle method in the second argument:
import { Spiceflow } from "spiceflow"; import { z } from "zod"; interface Env { KV: KVNamespace; QUEUE: Queue; SECRET: string; } const app = new Spiceflow() .state("env", null as Env | null) .get("/kv/:key", async ({ params, state }) => { const value = await state.env!.KV.get(params.key); return { key: params.key, value }; }) .post("/queue", async ({ request, state }) => { const body = await request.json(); await state.env!.QUEUE.send(body); return { success: true, message: "Added to queue" }; }); export default { fetch(request: Request, env: Env, ctx: ExecutionContext) { // Pass the env bindings to the app return app.handle(request, { env }); }, };
Fern SDK streaming support
When you use an async generator in your app, Spiceflow will automatically add
the required x-fern
extensions to the OpenAPI spec to support streaming.
Here is what streaming looks like in the Fern generated SDK:
import { ExampleSdkClient } from "./sdk-typescript"; const sdk = new ExampleSdkClient({ environment: "http://localhost:3000", }); // Get stream data const stream = await sdk.getStream(); for await (const data of stream) { console.log("Stream data:", data); } // Simple GET request const response = await sdk.getUsers(); console.log("Users:", response);
Add Package
deno add jsr:@fabriq/spiceflow
Import symbol
import * as spiceflow from "@fabriq/spiceflow";
Import directly with a jsr specifier
import * as spiceflow from "jsr:@fabriq/spiceflow";