Skip to content

Super Simple API framework, type safe, automatic OpenAPI, MCP support, client RPC, streaming with SSE

Notifications You must be signed in to change notification settings

remorses/spiceflow

Repository files navigation




spiceflow

fast, simple and type safe API framework



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 document based on your routes
  • Native support for Fern to generate docs and SDKs (see example docs here)
  • Returns extended JSON via superjson to encode types like Map, BigInt and Set (Spiceflow will add a field __superjsonMeta property to the JSON in case you use one of these types)
  • 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 and eval, Elysia is very difficult to contribue to because the app is generated by compiling the user routes with new 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 and Response 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 and c.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 either request.json() or request.formData() to get the body and validate it at the same time. This works by wrapping the request in a SpiceflowRequest instance, which has a json() and formData() method that parse the body and validate it. The returned data will have the correct schema type instead of any.

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 {
  ListToolsResultSchema,
  CallToolResultSchema,
  ListResourcesResultSchema,
} 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.

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)

About

Super Simple API framework, type safe, automatic OpenAPI, MCP support, client RPC, streaming with SSE

Resources

Stars

Watchers

Forks