Skip to content

Instantly share code, notes, and snippets.

@gabrielbauman
Last active October 5, 2023 22:20
Show Gist options
  • Save gabrielbauman/f3d1ec2be0edb183106946db76b521e6 to your computer and use it in GitHub Desktop.
Save gabrielbauman/f3d1ec2be0edb183106946db76b521e6 to your computer and use it in GitHub Desktop.
RPC-style Cloudflare durable objects
/**
* RpcDurableObject is a base class for DurableObjects that want to be called in an RPC style. Derive your object
* from this class and implement your methods as normal methods. There is no need to implement the fetch method. Then,
* use the buildRpcClient function to create a proxy object and call methods on the durable object as if it were a
* local object!
*
* This hides the complexity of calling a Durable Object from a worker and allows you to focus on the business logic
* of your application. It also allows you to use TypeScript to type your RPC methods; your worker and the proxy object
* should both implement the same interface containing the RPC methods.
*
* Exceptions thrown by the RPC methods will be caught and returned to the caller as a 500 error, then rethrown as
* RpcDurableObjectError by the proxy object. This allows you to catch and handle exceptions as normal in your worker
* code.
*
* @example
* import { RpcDurableObject } from './RpcDurableObject';
*
* interface GreetingOperations {
* hello(name: string): Promise<string>;
* }
*
* export class GreetingDurableObject extends RpcDurableObject implements GreetingOperations {
*
* async hello(name: string): Promise<string> {
* return `Hello ${name}!`;
* }
*
* // no fetch method needed!
* }
*/
export abstract class RpcDurableObject implements DurableObject {
/**
* An implementation of the fetch method that handles RPC calls from proxies created by buildRpcClient. You should
* not to override this method. Instead, implement your RPC methods as normal methods on your derived class.
* @param request
*/
public async fetch(request: Request) {
try {
// If not a post request, throw an error
if (request.method !== 'POST') {
// noinspection ExceptionCaughtLocallyJS
throw new Error(
`Only POST requests are supported by RpcDurableObject, got ${request.method}`
);
}
// Extract method name from URL
const url = new URL(request.url);
const segments = url.pathname.split('/');
if (segments.length !== 3) {
// noinspection ExceptionCaughtLocallyJS
throw new Error(`Invalid URL for RPC endpoint: ${request.url}`);
}
const operation = segments.pop()!;
// Extract arguments from request body
const body = await request.text();
const args = body.length > 0 ? JSON.parse(body) : [];
// Call the operation's corresponding method with the parameters from the body and return the result.
try {
// Check if there is a method for the specified operation
if (
operation in this &&
typeof this[operation as keyof this] === 'function'
) {
// Execute the method and return the result
const result = await (
this[operation as keyof this] as unknown as (...args: any[]) => any
)(...args);
//console.log(`${operation}(${args.map((o: any) => JSON.stringify(o)).join(', ')}) returned ${JSON.stringify(result)}`);
return new Response(JSON.stringify(result), { status: 200 });
}
return new Response(
`Implementation of ${operation}() not found on durable object`,
{ status: 404 }
);
} catch (error: any) {
return new Response(
`Remote procedure ${operation}(${args.join(', ')}) failed: ${
error.stack
}`,
{ status: 500 }
);
}
} catch (error: any) {
return new Response(
`Failed to call remote procedure: ${
error.hasOwnProperty('message') ? error.message : (error as string)
}`,
{
status: 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
}
}
export class RpcDurableObjectError extends Error {
constructor(message: string) {
super(message);
}
}
/**
* Creates a proxy for an RpcDurableObject that allows you to call its methods as if it were a local object. Hides the
* complexity of calling the fetch API on the Durable Object and handles parsing requests and responses etc.
* @param stub The Durable Object stub to create a proxy for
* @returns A proxy object that can be used to call methods on the Durable Object
* @typeParam OPS The interface that defines the RPC methods. Should be implemented by the durable object.
* @example
* // In your worker...
* import { buildRpcClient } from './RpcDurableObject';
* const id = env.TEST_DURABLE_OBJECT.newUniqueId();
* const obj = env.TEST_DURABLE_OBJECT.get(id);
* const stub = buildRpcClient<GreetingOperations>(obj);
*
* // Just call the methods in GreetingOperations and all the HTTP is handled for you!
* stub.hello('Gabriel');
*/
export function buildRpcClient<OPS = unknown>(stub: DurableObjectStub): OPS {
return new Proxy(stub, {
get(target: DurableObjectStub, rpcMethod: string) {
// Check if the method exists on the target object. If it does, return it.
if (rpcMethod in target) {
return (target as any)[rpcMethod];
}
//console.debug(`Calling ${rpcMethod}() on durable object...`);
// If method doesn't exist already, return a method that calls the fetch method on the target object
return async function (...args: any[]) {
const response = await target.fetch(
new Request(`https://${target.name}/${target.id}/${rpcMethod}`, {
body: JSON.stringify(args),
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
}
})
);
// If the response is not 200, a remote exception occurred. Throw it.
if (response.status !== 200)
throw new RpcDurableObjectError(await response.text());
// Otherwise, return the response body
return response
.text()
.then((text) => (text.length > 0 ? JSON.parse(text) : undefined));
};
}
}) as OPS;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment