Last active
October 5, 2023 22:20
-
-
Save gabrielbauman/f3d1ec2be0edb183106946db76b521e6 to your computer and use it in GitHub Desktop.
RPC-style Cloudflare durable objects
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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