Introduction
The @effect/rpc library facilitates the development of remote procedure call (RPC) systems in TypeScript, enhancing application scalability and maintainability. It provides a type-safe environment that reduces runtime errors by aligning with TypeScript's strong typing. This library simplifies the creation of network-exposed services, handling the intricacies of data serialization and network communication, allowing developers to concentrate on core business logic. Its features support custom serialization, error handling, and middleware, making it adaptable for diverse application needs.
Quickstart
Declaring Requests
The RpcGroup and Rpc modules can be used alongside the Schema module to
define requests and responses.
Here we are defining a request to retrieve a list of users, a request to
retrieve a user by ID, and a request to create a new user.
import { Rpc, RpcGroup } from "@effect/rpc"
import { Schema } from "effect"
export class User extends Schema.Class<User>("User")({
id: Schema.String,
name: Schema.String
}) {}
export class UserRpcs extends RpcGroup.make(
Rpc.make("UserList", {
success: User,
stream: true
}),
Rpc.make("UserById", {
success: User,
error: Schema.String,
payload: {
id: Schema.String
}
}),
Rpc.make("UserCreate", {
success: User,
payload: {
name: Schema.String
}
})
) {}
Implementing the handlers
This section introduces how to implement the rpc handlers, using an imaginary database setup to manage user data.
import type { Rpc } from "@effect/rpc"
import { Effect, Layer, Ref, Stream } from "effect"
import { User, UserRpcs } from "./request.js"
class UserRepository extends Effect.Service<UserRepository>()(
"UserRepository",
{
effect: Effect.gen(function* () {
const ref = yield* Ref.make<Array<User>>([
new User({ id: "1", name: "Alice" }),
new User({ id: "2", name: "Bob" })
])
return {
findMany: ref.get,
findById: (id: string) =>
Ref.get(ref).pipe(
Effect.andThen((users) => {
const user = users.find((user) => user.id === id)
return user
? Effect.succeed(user)
: Effect.fail(`User not found: ${id}`)
})
),
create: (name: string) =>
Ref.updateAndGet(ref, (users) => [
...users,
new User({ id: String(users.length + 1), name })
]).pipe(Effect.andThen((users) => users[users.length - 1]))
}
})
}
) {}
export const UsersLive: Layer.Layer<
Rpc.Handler<"UserList"> | Rpc.Handler<"UserById"> | Rpc.Handler<"UserCreate">
> = UserRpcs.toLayer(
Effect.gen(function* () {
const db = yield* UserRepository
return {
UserList: () => Stream.fromIterableEffect(db.findMany),
UserById: ({ id }) => db.findById(id),
UserCreate: ({ name }) => db.create(name)
}
})
).pipe(
Layer.provide(UserRepository.Default)
)
Serving the API
This part explains how to serve the API using the handlers we defined earlier.
import { HttpRouter } from "@effect/platform"
import { BunHttpServer, BunRuntime } from "@effect/platform-bun"
import { RpcSerialization, RpcServer } from "@effect/rpc"
import { Layer } from "effect"
import { UsersLive } from "./handlers.js"
import { UserRpcs } from "./request.js"
const RpcLayer = RpcServer.layer(UserRpcs).pipe(Layer.provide(UsersLive))
const HttpProtocol = RpcServer.layerProtocolHttp({
path: "/rpc"
}).pipe(Layer.provide(RpcSerialization.layerNdjson))
const Main = HttpRouter.Default.serve().pipe(
Layer.provide(RpcLayer),
Layer.provide(HttpProtocol),
Layer.provide(BunHttpServer.layer({ port: 3000 }))
)
BunRuntime.runMain(Layer.launch(Main))
Testing the API with curl
Use this curl command to test if the API is operational:
curl -X POST http://localhost:3000/rpc \
-H "Content-Type: application/json" \
-d $'{"_tag": "Request", "id": "123", "tag": "UserList", "payload": {}, "traceId": "traceId", "spanId": "spanId", "sampled": true, "headers": {} }\n'
Using your new backend on the client
Let's now move to the client-side code and embrace the power of end-to-end typesafety.
import { FetchHttpClient } from "@effect/platform"
import { RpcClient, RpcSerialization } from "@effect/rpc"
import { Chunk, Effect, Layer, Option, Stream } from "effect"
import { UserRpcs } from "./request.js"
const ProtocolLive = RpcClient.layerProtocolHttp({
url: "http://localhost:3000/rpc"
}).pipe(
Layer.provide([
FetchHttpClient.layer,
RpcSerialization.layerNdjson
])
)
const program = Effect.gen(function* () {
const client = yield* RpcClient.make(UserRpcs)
let users = yield* Stream.runCollect(client.UserList({}))
if (Option.isNone(Chunk.findFirst(users, (user) => user.id === "3"))) {
console.log(`Creating user "Charlie"`)
yield* client.UserCreate({ name: "Charlie" })
users = yield* Stream.runCollect(client.UserList({}))
} else {
console.log(`User "Charlie" already exists`)
}
return users
}).pipe(Effect.scoped)
program.pipe(Effect.provide(ProtocolLive), Effect.runPromise).then(console.log)
Defining middleware
To add middleware to the RPC server (& optionally the client), you can use the
RpcMiddleware module.
The first step is to define the middleware context tag, which is used to both
implement and access the middleware.
import { RpcMiddleware } from "@effect/rpc"
import { Context } from "effect"
import type { User } from "./request.js"
export class CurrentUser extends Context.Tag("CurrentUser")<
CurrentUser,
User
>() {}
export class AuthMiddleware extends RpcMiddleware.Tag<AuthMiddleware>()(
"AuthMiddleware",
{
provides: CurrentUser,
requiredForClient: true
}
) {}
Implementing middleware
Once the middleware context tag is defined, you can then use it in a RpcGroup
to apply it to various RPCs.
When it has been applied, you can then implement the middleware logic and add it
to your server and client.
import { Headers } from "@effect/platform"
import { Rpc, RpcClient, RpcGroup, RpcMiddleware, RpcServer } from "@effect/rpc"
import { Effect, Layer, Schema } from "effect"
import { AuthMiddleware } from "./middleware.js"
import { User } from "./request.js"
export class UserRpcs extends RpcGroup.make(
Rpc.make("UserById", {
success: User,
payload: {
id: Schema.String
}
})
.middleware(AuthMiddleware)
)
.middleware(AuthMiddleware) {}
export const AuthLive: Layer.Layer<AuthMiddleware> = Layer.succeed(
AuthMiddleware,
AuthMiddleware.of(({ headers, payload, rpc }) =>
Effect.succeed(new User({ id: "123", name: "Logged in user" }))
)
)
RpcServer.layer(UserRpcs).pipe(Layer.provide(AuthLive))
export const AuthClientLive: Layer.Layer<
RpcMiddleware.ForClient<AuthMiddleware>
> = RpcMiddleware.layerClient(AuthMiddleware, ({ request, rpc }) =>
Effect.succeed({
...request,
headers: Headers.set(request.headers, "authorization", "Bearer token")
})
)
export class UsersClient extends Effect.Service<UsersClient>()("UsersClient", {
scoped: RpcClient.make(UserRpcs),
dependencies: [AuthClientLive]
}) {}