Experimenting with Cloudflare Workers RPC
RPC is cool and all... until it stops working unexpectedly
Published 4/20/2024
For a while now, I have been working on a project called FDGL (Factorio Distributed Griefer List). The main point of the whole project is to have a central database of violations of rules (we call violations “reports” and rules “categories”), so that any record can be found by everyone. Recently, I began on a rework of the project so that it works better and I decided to go with Cloudflare Workers as my hosting of choice. Since multiple services use the same exact backend, I decided to utilize the new RPC support that Workers have been given. I have encountered some issues when building this, so I decided that I will share my experience with Workers RPC.
After getting the initial setup right - it took me ages, which is however my fault for not reading the docs
correctly - I was able to communicate between my two workers. Great! Now I needed to migrate my whole backend
into its separate worker so that it could be exposed with a backend, and accessed by a Discord bot. I naively
thought that any implementation would work. Previously, I wrote multiple API wrappers for clients. Every “base route”
(such as “/users”) would be its own class with methods for various endpoints, so you could access
say a “/users/:id” on api.users.fetchById(id)
. This setup is not too difficult to implement. I decided to
do essentially the same, but I would replace calls to an API with calls to the database and some backend logic.
Additionally, I needed to add some getters to be able to access this over the RPC boundary.
class Users {
constructor(private db: Kysely<DB>) {}
async fetchById(id: string): Promise<User | null> {
return await this.db
.selectFrom("users")
.selectAll()
.where("id", "=", id)
.executeTakeFirst()
}
}
class API {
#users: Users
constructor(private db: Kysely<DB>) {
this.#users = new Users(db)
}
get users() {
return this.#users
}
}
Amazing! Now, to get it set up in a Worker instance, test API.users.fetchById
, and…
I get an error. The RPC receiver does not implement the method "categories".
What?
That makes no sense. categories
is a property that I can access through a getter.
That should definitely work. Well, let’s replace the call with a users__getUserById
and implement that on the RPC worker. Huh. That works. What’s up?
As it turns out, reading the announcements does in fact help. It might not be in the docs,
but the announcement post directly states “you can pass Structured Clonable
types as the params or return value of an RPC”. Those types are the basic types,
such as Array, Boolean, Map, Number, String, and most importantly, Object - but
only “plain” objects. The announcement post also states that I can pass functions
in and out, or objects with methods - this is important for later. Searching
MDN and the internet tells me that a plain object is basically just {}
.
The issue is that a class instance is not a simple
{}
creation, so what does that mean for me and my backend?
Turns out, I can utilize some interesting features of JavaScript and TypeScript in order to
achieve my goal, Function.prototype.bind()
being the main star here. You can call .bind()
on any function, and as a first parameter, you provide it it’s this
variable. Any time the
bound function is called, instead of referencing itself, it can reference the object provided
as its this
. See the modified example from MDN below.
const module = {
x: 42,
getX: function () {
return this.x;
},
};
const boundGetX = module.getX.bind(module);
const unboundGetX = module.getX
console.log(unboundGetX(), boundGetX()) // undefined, 42
module.x = 44
console.log(unboundGetX(), boundGetX()) // undefined, 44
I can utilize it pretty nicely. Remember how we can pass objects with methods
through Workers RPC? Well, bind
allows us to create a new function which calls
the original with the new parameters. This means that essentially, we aren’t
calling module.getX
, but a function that through inner JS workings calls
module.getX
eventually and returns our desired answer. A similar process
can be applied to classes, but you wouldn’t usually do this - the
bound function does the same thing as the unbound one, only with some inner JS
quirks in between. However, these inner JS quirks are what we are after here -
we get a new function that returns our result, but importantly, we don’t need to
pass the Users
object over RPC - instead, we can “recreate” it using .bind()
calls.
class Users {
constructor(private db: Kysely<DB>) {}
async fetchById(id: string): Promise<User | null> {
return await this.db
.selectFrom("users")
.selectAll()
.where("id", "=", id)
.executeTakeFirst()
}
}
class API {
#users: Users
constructor(private db: Kysely<DB>) {
this.#users = new Users(db)
}
get users() {
return {
fetchById: this.#users.fetchById.bind(this.#users)
}
}
}
In the example above, a call on API.users.fetchById
would be proxied, and thus
a direct object is not passed - that means we pass all criteria for Workers RPC
to be able to send our methods over successfuly! With a dash of type magic, we can
improve our getter to error if we don’t have some methods from the Users
class
implemented, and now we have backend working again - just over RPC.
type PickMatching<T, V> = {
[K in keyof T as T[K] extends V ? K : never]: T[K];
};
// biome-ignore lint/complexity/noBannedTypes: it's okay here as it is only a picker
type ExtractMethods<T> = PickMatching<T, Function>;
class API {
// ...
get users() {
return {
fetchById: this.#users.fetchById.bind(this.#users)
} satisfies ExtractMethods<Users>
}
}
There are a couple more interesting things that I’ll be solving with Workers in the very close future, such as synchronization of state with distributed clients that I do not directly control, but those will need to wait until I actually have any idea on how to solve them (properly).