Skip to content

Instantly share code, notes, and snippets.

@colelawrence
Last active March 27, 2024 22:10
Show Gist options
  • Save colelawrence/ba912db3cd1539ee820c3fe5e67654df to your computer and use it in GitHub Desktop.
Save colelawrence/ba912db3cd1539ee820c3fe5e67654df to your computer and use it in GitHub Desktop.
Create strongly typed Durable Objects with TRPC wrappers (requires trpc@10)
import { createTRPCProxyClient, httpLink } from "@trpc/client";
import { AnyRouter, initTRPC, MaybePromise, Router } from "@trpc/server";
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import type { ParserWithInputOutput, ParserWithoutInput } from "@trpc/server/dist/core/parser";
import type { AnyRouterDef } from "@trpc/server/dist/core/router";
import type { ResponseMetaFn } from "@trpc/server/dist/http/internals/types";
import { getParseFn } from "./getParseFn";
export type TrpcDurableObjectRouter<Router extends AnyRouter> = {
router: Router,
/** Allow mutating the response */
responseMeta?: ResponseMetaFn<Router>;
}
export interface TRPCDurableObjectConstructor<Env, Router extends AnyRouter> {
new(
controller: DurableObjectState,
env: Env,
): DurableObject
getClient(getStub: () => DurableObjectStub): ReturnType<typeof createTRPCProxyClient<Router>>
}
export type DurableObjectContext<Env> = {
env: Env
}
type AnyTRPCDurableObjectRouter<Env> = Router<AnyRouterDef<DurableObjectContext<Env>>>;
export function initTRPCDurableObject<Env>(envParser?: ParserWithoutInput<Env> | ParserWithInputOutput<unknown, Env>) {
const envParserFn = envParser ? getParseFn(envParser) : undefined
return {
t: initTRPC<{ ctx: DurableObjectContext<Env> }>()(
// Is this transformer + errorFormatter something we should allow configuring?
),
object: function createTRPCDurableObject<R extends AnyTRPCDurableObjectRouter<Env>>(
createObject: (
state: DurableObjectState,
env: Env,
/** indentity function to help with type checking */
defineReturn: <X extends TrpcDurableObjectRouter<AnyTRPCDurableObjectRouter<Env>>>(value: X) => X
) => Promise<TrpcDurableObjectRouter<R>>
): TRPCDurableObjectConstructor<Env, R> {
const DUMMY_ENDPOINT = "/trpc";
return class TRPCDurableObjectClass {
public _fetch: Promise<(req: Request) => Promise<Response>>
public static getClient(getStub: () => MaybePromise<DurableObjectStub>) {
let stub: undefined | DurableObjectStub
return createTRPCProxyClient<any>({
async fetch(...args) {
if (!stub) stub = await Promise.resolve(getStub())
// logic borrowed from the rate limiter in `cloudflare/workers-chat-demo`
// https://github.com/cloudflare/workers-chat-demo/blob/b4462b1be2dd110c313ed7c9baed92044e03181c/src/chat.mjs#L510-L542
try {
// attempt fetch on the stub we have
return await stub.fetch(...args);
} catch (err) {
// `fetch()` threw an exception. This is probably because the limiter has been
// disconnected. Stubs implement E-order semantics, meaning that calls to the same stub
// are delivered to the remote object in order, until the stub becomes disconnected, after
// which point all further calls fail. This guarantee makes a lot of complex interaction
// patterns easier, but it means we must be prepared for the occasional disconnect, as
// networks are inherently unreliable.
//
// Anyway, get a new stub and try again. If it fails again, something else is probably
// wrong.
stub = await Promise.resolve(getStub());
return await stub.fetch(...args);
}
},
links: [
httpLink({
// need to actually use a middleware for redirecting the fetch request
// see https://discord.com/channels/595317990191398933/1010005243548934227/1010020619993239623
url: `http://dummy-url` + DUMMY_ENDPOINT,
})
],
}) as any; // as any return to avoid TypeScript "excessvely deep type instantiation"
}
constructor(
state: DurableObjectState,
env: Env
) {
// hmm...
if (envParserFn) env = envParserFn(env) as any
this._fetch = state.blockConcurrencyWhile(async () => {
const trpcObj = await Promise.resolve(createObject(state, env, identity))
// const createContext = trpcObj.createContext.bind(trpcObj);
return async (req) => {
const res = await fetchRequestHandler<AnyRouter>({
endpoint: DUMMY_ENDPOINT,
req,
router: trpcObj.router,
responseMeta: trpcObj.responseMeta,
// createContext,
});
return res;
};
})
}
async fetch(req: Request): Promise<Response> {
return (await this._fetch)(req);
}
}
}
}
}
function identity<T>(x: T): T {
return x
}
export type ParseFn<T> = (value: unknown) => T | Promise<T>;
export function getParseFn<T>(procedureParser: unknown): ParseFn<T> {
const parser = procedureParser as any;
if (typeof parser === 'function') {
// ProcedureParserCustomValidatorEsque
return parser;
}
if (typeof parser.parseAsync === 'function') {
// ProcedureParserZodEsque
return parser.parseAsync.bind(parser);
}
if (typeof parser.parse === 'function') {
// ProcedureParserZodEsque
return parser.parse.bind(parser);
}
if (typeof parser.validateSync === 'function') {
// ProcedureParserYupEsque
return parser.validateSync.bind(parser);
}
if (typeof parser.create === 'function') {
// ProcedureParserSuperstructEsque
return parser.create.bind(parser);
}
throw new Error('Could not find a validator fn');
}
@colelawrence
Copy link
Author

@maurerbot, I use something like this for an MVP project that's built around Durable Objects for storage. I don't recall the differences between what's posted here and what exists in my codebase at this time. I've added several features like rate limiting, open telemetry reporting, and durable storage adapters with zod with it.

The codebase I have has diverged a lot from this sample, so it's hard to recreate an example.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment