tRPC-style Safe RPC methods for Cloudflare Durable Objects
Safe Durable Objects brings type-safe, validated RPC methods to Cloudflare Durable Objects with a developer experience inspired by tRPC. It uses Zod for runtime validation and provides full TypeScript support.
You can access the router and schemas via YourClass.prototype._def
or YourClass.prototype.route._def
as you would with tRPC. This is extremely powerful as you can convert the schemas to a JSON schema and use them to convert your durable object methods into callable tools for your AI agents.
- 🔒 Type-safe: Full TypeScript support with end-to-end type safety
- ✅ Runtime validation: Input and output validation using Zod schemas
- 🎯 tRPC-inspired API: Familiar developer experience with
.input()
,.output()
, and.implement()
npm install safe-durable-objects zod
# or
pnpm add safe-durable-objects zod
# or
yarn add safe-durable-objects zod
# or
bun add safe-durable-objects zod
You'll also need @cloudflare/workers-types
for TypeScript support:
npm install -D @cloudflare/workers-types
Here's a complete example of how to use Safe Durable Objects:
import { z } from "zod/v4";
import { SafeDurableObjectBuilder } from "safe-durable-objects";
import { DurableObject } from "cloudflare:workers";
type State = {
count: number;
lastMessage: string;
};
type Env = {
MY_DURABLE_OBJECT: DurableObjectNamespace<MyDurableObject>;
};
export class MyDurableObject extends SafeDurableObjectBuilder(
// This is the base class
class extends DurableObject<Env> {
state: State;
// important: make sure to make the ctx and env public, else you won't be able to access them in the router and typescript will complain
constructor(public ctx: DurableObjectState, public env: Env) {
super(ctx, env);
this.state = {
count: 0,
lastMessage: "",
};
}
setState(state: State) {
this.state = state;
}
},
(fn) => ({
hello: fn
.input(z.string())
.output(z.object({ message: z.string(), id: z.string() }))
.implement(function ({ ctx, input }) {
// You can access the base class methods via `this`
const state = this.state;
this.setState({
count: state.count + 1,
lastMessage: input,
});
return {
message: `Hello, ${input}!! state: ${JSON.stringify(state)}`,
id: ctx.id.toString(),
};
}),
ping: fn.output(z.object({ message: z.string() })).implement(function () {
return {
message: "pong",
};
}),
})
) {}
export default {
async fetch(request, env, ctx) {
const stub = env.MY_DURABLE_OBJECT.get(
env.MY_DURABLE_OBJECT.idFromName("test")
);
const res = await stub.hello("world");
return Response.json(res);
},
} as ExportedHandler<Env>;
Creates a new Durable Object class with safe RPC methods.
BaseClass
: Your base Durable Object classrouterBuilder
: A function that receives a route builder and returns an object with your RPC methods
The route builder provides a fluent API for defining RPC methods:
fn.input(inputSchema).output(outputSchema).implement(handler);
// or
fn.input(inputSchema).implement(handler); // output schema is optional
Note: Only zod/v4
schemas are supported
Defines the input validation schema using Zod. The input will be validated at runtime.
Defines the output validation schema using Zod. The output will be validated at runtime.
Implements the actual RPC method logic. The handler receives:
ctx
: The DurableObjectStateenv
: The environment bindingsinput
: The validated input (typed according to your input schema)
If you use a function
instead of an arrow function in the implement block, you can access the base class via this
import { z } from "zod/v4";
import { SafeDurableObjectBuilder } from "safe-durable-objects";
import { DurableObject } from "cloudflare:workers";
export class Counter extends SafeDurableObjectBuilder(
class extends DurableObject<Env> {
private count = 0;
// important: make sure to make the ctx and env public, else you won't be able to access them in the router and typescript will complain
constructor(public ctx: DurableObjectState, public env: Env) {
super(ctx, env);
}
async getCount() {
return this.count;
}
async setCount(value: number) {
this.count = value;
}
},
(fn) => ({
increment: fn
.input(z.object({ by: z.number().optional().default(1) }))
.output(z.object({ count: z.number() }))
.implement(async function ({ input }) {
const currentCount = await this.getCount();
const newCount = currentCount + input.by;
await this.setCount(newCount);
return { count: newCount };
}),
getCount: fn
.input(z.void())
.output(z.object({ count: z.number() }))
.implement(async function () {
const count = await this.getCount();
return { count };
}),
})
) {}
Safe Durable Objects automatically handles validation errors. If input validation fails, a ZodError
will be thrown. If output validation fails, it will also throw a ZodError
.
const result = await stub.hello("invalid input").catch((error) => {
/*handle here*/
});
Contributions are welcome! Please feel free to submit a Pull Request.
MIT © Iterate
If you have any questions or need help, please open an issue on GitHub.