Skip to content

Calling a Durable Object

A caller reaches a room by naming it. It doesn’t need to know where the room runs, or whether it’s running at all: it sends the call to the control plane, and the control plane finds the room or starts it.

ApiWorker or LambdaGatewaycontrol planelobbyRoom, on a nodejoin() on lobby, with the tokenlobby is asleepstart on a free nodejoin()4
The caller names the room and the gateway does the rest: it starts the room if it's asleep, then passes the call on. The caller never needs a route to the nodes.

A caller yields the class in its Construction phase, like any other binding:

src/api.ts
Effect.gen(function* () {
const rooms = yield* Room;
return {
fetch: Effect.gen(function* () {
// ...
}),
};
});

rooms is the class’s namespace: every room, addressed by name. Yielding it gives the caller the control plane’s address and a token to call it with. It works the same in a Cloudflare Worker, a Lambda, a container or a Hetzner Service, because the caller only speaks HTTP.

src/api.ts
const lobby = rooms.getByName("lobby");

getByName doesn’t contact anything. It returns a stub for the lobby, and the first call through the stub reaches it. A name nobody has used before gets a fresh room.

src/api.ts
const visitors = yield* lobby.join();

The stub has the class’s methods, typed from it, so join() returns an Effect<number> because the room’s join does. Each call runs in the room, against the lobby’s state, wherever the lobby is running.

A method that returns a Stream sends its values as they’re produced, rather than all at the end. A room can stream its messages instead of returning them as one array:

src/room.ts
history: () =>
Stream.fromIterableEffect(
Effect.gen(function* () {
const rows = yield* sql.exec<{ author: string; text: string }>(
"SELECT author, text FROM messages",
);
return yield* rows.toArray();
}),
),

The caller consumes it like any other stream:

src/api.ts
yield* lobby.history().pipe(Stream.runForEach((message) => Effect.log(message.text)));

When a room says no, it fails with a tagged error. A full room refuses another visitor:

src/room.ts
export class RoomFull extends Data.TaggedError("RoomFull")<{ readonly limit: number }> {}
src/room.ts
join: () => (visitors >= 50 ? Effect.fail(new RoomFull({ limit: 50 })) : admit()),

The error crosses the wire with its tag and its fields, so the caller catches it by name:

src/api.ts
yield* lobby.join().pipe(
Effect.catchTag("RoomFull", (error) => Effect.log(`the lobby holds ${error.limit}`)),
);

A room with a fetch handler answers HTTP requests. A caller forwards the request it received:

src/api.ts
const request = yield* HttpServerRequest.HttpServerRequest;
return yield* lobby.fetch(request);

The room sees the request as it arrived, and its response goes back to the caller unchanged.

A Durable Object calls another the same way an Api does: it yields the class in its Construction phase. A Greeter that welcomes people into a room:

src/greeter.ts
export default class Greeter extends Rivet.DurableObject<Greeter>()(
"Greeter",
{ main: import.meta.url },
Effect.gen(function* () {
const rooms = yield* Room;
return Effect.gen(function* () {
return {
welcome: (room: string) =>
rooms
.getByName(room)
.join()
.pipe(Effect.map((visitors) => `you're visitor ${visitors}`)),
};
});
}).pipe(Effect.provide(Default)),
) {}

The call goes through the control plane too, even when both run on the same pool.

Importing Room brings its implementation into the caller’s bundle. To import only the declaration, split the class from its code. See Durable Object.

Next, RivetKit Actors: Actors written with Rivet’s own library, deployed as they are.