Skip to main content
oRPC is a modern RPC framework with first-class TypeScript support. ff-serv provides a handler that wraps @orpc/server routers.

Installation

bun add @orpc/server @orpc/client

Basic Usage

import { createFetchHandler } from 'ff-serv'
import { oRPCHandler } from 'ff-serv/orpc'
import { os } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch'
import { Effect } from 'effect'

const program = Effect.gen(function* () {
  // Define your oRPC router
  const router = {
    health: os.handler(() => ({ status: 'ok' })),
    greeting: os.handler((input: { name: string }) => `Hello, ${input.name}!`),
  }

  // Create the oRPC handler
  const handler = new RPCHandler(router)

  const fetch = yield* createFetchHandler([
    oRPCHandler(handler),
  ])

  Bun.serve({ port: 3000, fetch })
})

Effect.runPromise(program)

With Effect Context

Pass Effect-based options to provide context to your oRPC handlers:
import { Context, Effect } from 'effect'

class Database extends Context.Tag('Database')<
  Database,
  { query: (sql: string) => Effect.Effect<unknown[]> }
>() {}

const router = {
  users: os.handler(async () => {
    // Access context in oRPC handler
    const db = await Effect.runPromise(Database)
    return db.query('SELECT * FROM users')
  }),
}

const program = Effect.gen(function* () {
  const db = yield* Database

  const fetch = yield* createFetchHandler([
    oRPCHandler(
      new RPCHandler(router),
      // Provide context to oRPC
      { context: { database: db } }
    ),
  ])

  Bun.serve({ port: 3000, fetch })
})

Dynamic Options

Options can be computed per-request:
oRPCHandler(
  handler,
  (request) => {
    const token = request.headers.get('Authorization')
    return {
      context: {
        userId: parseToken(token),
      },
    }
  }
)

Options as Effect

Options can also be an Effect:
import { HttpClient } from '@effect/platform'

oRPCHandler(
  handler,
  Effect.gen(function* () {
    const client = yield* HttpClient.HttpClient
    // Fetch config from external service
    const config = yield* client.get('https://api.example.com/config')
    
    return {
      context: { config: yield* config.json },
    }
  })
)

Client Setup

Connect from the client using @orpc/client:
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import type { RouterClient } from '@orpc/server'

type Router = typeof router

const client: RouterClient<Router> = createORPCClient(
  new RPCLink({ url: 'http://localhost:3000' })
)

// Fully typed calls
const result = await client.greeting({ name: 'Alice' })
console.log(result) // "Hello, Alice!"

With ff-effect Client Wrapper

Use ff-effect’s wrapClient to call oRPC from Effect code:
import { wrapClient } from 'ff-effect'
import { UnknownException } from 'effect/Cause'
import { Effect } from 'effect'

const program = Effect.gen(function* () {
  const call = wrapClient({
    client,
    error: ({ cause }) => new UnknownException(cause),
  })

  const result = yield* call((c) => c.greeting({ name: 'Bob' }))
  yield* Effect.log(result)
})

Effect.runPromise(program)

Complete Example

src/server.ts
import { createFetchHandler, basicHandler } from 'ff-serv'
import { oRPCHandler } from 'ff-serv/orpc'
import { os } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch'
import { Effect, Context } from 'effect'

// Define a service
class Config extends Context.Tag('Config')<
  Config,
  { apiKey: string }
>() {}

const program = Effect.gen(function* () {
  const config = yield* Config

  // Define oRPC router with context
  const router = {
    health: os.handler(() => ({ status: 'ok' })),
    
    protected: os.handler((input: { data: string }) => {
      return {
        message: `Processed with key: ${config.apiKey}`,
        data: input.data,
      }
    }),
  }

  const fetch = yield* createFetchHandler([
    // oRPC endpoints at /rpc/*
    oRPCHandler(new RPCHandler(router), {
      context: { config },
    }),

    // Regular HTTP endpoint
    basicHandler('/version', () => 
      Response.json({ version: '1.0.0' })
    ),
  ])

  Bun.serve({ port: 3000, fetch })
  yield* Effect.log('Server ready')
})

Effect.runPromise(
  program.pipe(
    Effect.provideService(Config, { apiKey: 'secret' })
  )
)
src/client.ts
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'
import type { RouterClient } from '@orpc/server'
import type { router } from './server'

const client: RouterClient<typeof router> = createORPCClient(
  new RPCLink({ url: 'http://localhost:3000' })
)

const health = await client.health()
console.log(health) // { status: 'ok' }

const result = await client.protected({ data: 'test' })
console.log(result)
// { message: 'Processed with key: secret', data: 'test' }

Type Signature

function oRPCHandler<T extends Context, E, R>(
  handler: FetchHandler<T>,
  opt?: 
    | FriendlyStandardHandleOptions<T>
    | Effect.Effect<FriendlyStandardHandleOptions<T>, E, R>
    | ((request: Request) => 
        | FriendlyStandardHandleOptions<T>
        | Effect.Effect<FriendlyStandardHandleOptions<T>, E, R>
      )
): Handler<'oRPCHandler', never>
  • handler: An oRPC FetchHandler (from @orpc/server/fetch)
  • opt: Context and options to pass to oRPC, as a value, Effect, or request function
The oRPC handler matches all requests by default. Place it after any custom basicHandler routes if you want to handle specific paths separately.

Build docs developers (and LLMs) love