I’ve previously written about Cloudflare’s Durable Objects (DOs), and I’ve written about TanStack Start too many times to list here (you could start here, though, or better, take my course).
This post covers some tips and tricks I’ve found when using them together. A few of these tips are solutions to problems that may be solved in TanStack by the time you read this. But most, like using Middleware and tagged WebSocket connections, are just ways to make the most of great features.
Let’s get started!
Middleware to Simplify Durable Object Creation
Durable Objects can only be called from the server, not from the browser. A Durable Object is not a publicly addressable resource on the internet; it’s an internal resource that can only be connected to from within Cloudflare infrastructure. But since our web app is (presumably) running within Cloudflare, we can absolutely connect to it from server-side code in our web app. If your web app is hosted on a vanilla Node process, or on Vercel, Durable Objects may not be a great tool to lean on, since you’d have to stand up a new Worker just to communicate with the Durable Object.
In prior posts, I’ve shown code like this for getting an instance of the Durable Object.
export const getWorkoutTemplateAIGenerationDurableObject = async (context: AuthContext) => {
const userId = await requireUserId(context);
const { WorkoutTemplateAIGenerationDO } = env;
const doId = WorkoutTemplateAIGenerationDO.idFromName(userId);
return WorkoutTemplateAIGenerationDO.get(doId);
};Code language: TypeScript (typescript)
Code like that could be used like this:
export const getAiSessionsServerFn = createServerFn({ method: "POST" })
.handler(async ({ context }): Promise<SessionSummary[]> => {
const wtDo = await getWorkoutTemplateAIGenerationDurableObject(context);
return wtDo.getSessions();
});Code language: TypeScript (typescript)
But what if the signature of getWorkoutTemplateAIGenerationDurableObject were to change in some way? We wouldn’t want to update every call site that uses this method. Yes, of course an agent would make such a refactor trivial. Nonetheless, there’s a TanStack feature that’s built for this kind of thing: Middleware.
First Attempt
const withWtDo = createMiddleware({ type: "function" }).server(async ({ context, next }) => {
const durableObject = await getWorkoutTemplateAIGenerationDurableObject(context);
return next({
context: {
wtDo: durableObject,
},
});
});Code language: TypeScript (typescript)
Unfortunately, this creates TypeScript errors, since context is undefined. This was a surprise, since I’m using Global Middleware to add authentication info, so it should be available everywhere.
This is currently a limitation with TanStack’s static typings, but there’s an easy workaround.
The Solution
When we created global middleware, we did so in start.ts at the root of our project. Inside, we have this export (this is all covered in the linked docs above)
export const startInstance = createStart(() => ({
requestMiddleware: [csrfMiddleware, globalContextMiddleware, errorLoggingMiddleware],
functionMiddleware: [],
}));Code language: TypeScript (typescript)
As of now, middleware can have trouble picking up things added to context in global middleware, which I’m doing here with globalContextMiddleware.
The workaround, for now, is to just import that very same startInstance, and then simply call createMiddleware off of startInstance
export const withWtDo = startInstance.createMiddleware({ type: "function" }).server(async ({ context, next }) => {
const durableObject = await getWorkoutTemplateAIGenerationDurableObject(context);
return next({
context: {
wtDo: durableObject,
},
});
});Code language: TypeScript (typescript)
This works like a charm.
Now we can simply add the middleware to our server functions.
export const getAiSessionsServerFn = createServerFn({ method: "POST" })
.middleware([withWtDo])
.handler(async ({ context }): Promise<SessionSummary[]> => {
return context.wtDo.getSessions();
});Code language: TypeScript (typescript)
And our Durable Object instance will be available and waiting for us in context.
Tagged WebSocket Connections
Durable Objects have built-in WebSocket support. I’ve covered this in my other posts, but the short of it is, to accept and create a new WebSocket connection, you add a fetch method like so
fetch(request: Request): Response {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", {
status: 426,
});
}
// ...
const pair = new WebSocketPair();
const client = pair[0];
const server = pair[1];
this.ctx.acceptWebSocket(server);
return new Response(null, {
status: 101,
webSocket: client,
});Code language: TypeScript (typescript)
And then, to send out a message to all WebSocket connections, you do something like this:
sendMessage(sessionId: number, payload: Object) {
for (const socket of this.ctx.getWebSockets()) {
try {
socket.send(JSON.stringify(payload));
} catch {
// The socket may have disconnected before Cloudflare observed it.
socket.close(1011, "Unable to send message");
}
}
}Code language: TypeScript (typescript)
Filtering WebSocket Connections
But what if you want to separate or categorize WebSocket connections? In my prior post, I wrote about using AI to generate workouts. When the user enters a prompt, I call methods in my Durable Object to save it to a new session, then open a WebSocket connection for the results.
But what if the user has multiple sessions open at once? If a single session’s prompt results come back, when we do this
for (const socket of this.ctx.getWebSockets()) {
try {
socket.send(JSON.stringify(payload));
} catch {
// The socket may have disconnected before Cloudflare observed it.
socket.close(1011, "Unable to send message");
}
}Code language: TypeScript (typescript)
We’ll end up sending that prompt update to all sessions, even ones it doesn’t apply to. Sure we could add code to check and ignore irrelevant messages, but there’s a cleaner, more direct solution: tagged WebSocket connections.
Tagging WebSocket Connections
When we create (accept) a WebSocket connection, we have the option of providing one or more tags. These let us “tag” a WebSocket connection, and we can use those tags for later retrieval.
For the AI workout generation example I just mentioned, when a new WebSocket connection is created, I include the sessionId in the URL used to set up the new connection. Remember, we have to set up our own API route, and then manually call the fetch method on our Durable Object, and pass in the raw request object. That raw request object contains the original URL, and you can set up whatever URL for your API route you’d like. For mine, I included the sessionId as a route param.
fetch(request: Request): Response {
if (request.headers.get("Upgrade") !== "websocket") {
return new Response("Expected WebSocket", {
status: 426,
});
}
const url = new URL(request.url);
const sessionIdParam = url.pathname.split("/").at(-2) || "";Code language: TypeScript (typescript)
And then, when we call acceptWebSocket we can pass in a tag.
this.ctx.acceptWebSocket(server, [webSocketTag(sessionId)]);Code language: TypeScript (typescript)
The webSocketTag function takes in a sessionId and returns a string tag. Here’s the implementation:
const webSocketTag = (sessionId: number) => `session:${sessionId}`;Code language: TypeScript (typescript)
Why would I bother with a function for something so simple? Because I also use that same tag to retrieve WebSockets when I want to send a message, which I do like this:
sendMessage(sessionId: number, payload: Object) {
for (const socket of this.ctx.getWebSockets(webSocketTag(sessionId))) {
try {
socket.send(JSON.stringify(payload));
} catch {
// The socket may have disconnected before Cloudflare observed it.
socket.close(1011, "Unable to send message");
}
}
}Code language: TypeScript (typescript)
And with that, a given WebSocket only receives messages it cares about.
Return Types, Durable Objects, and Server Functions
Here’s a Durable Object method:
export class WorkoutTemplateAIGenerationDO extends DurableObject {
// ...
getSessions() {
const rows = this.db.select().from(sessionTable).all();
return rows;
}
// ...
}Code language: TypeScript (typescript)
The inferred return type of this method is:
{
id: number;
name: string;
createdAt: string;
}[];Code language: TypeScript (typescript)
And here’s our Server Function we use to call it:
export const getAiSessionsServerFn = createServerFn({ method: "POST" })
.middleware([withWtDo])
.handler(async ({ context }) => {
return context.wtDo.getSessions();
});Code language: TypeScript (typescript)
The inferred return type is this:
Promise<{
id: number;
name: string;
createdAt: string;
}[] & DisposableCode language: TypeScript (typescript)
Note the & Disposable. The array of objects with id, name, etc. is what comes back from the query. Disposable gets added on behind the scenes, as does Promise<T> wrapping the whole thing.
What may be especially surprising is that setting a return type on the DO’s method does not change this.
getSessions(): SessionSummary[] {
const rows = this.db.select().from(sessionTable).all();
return rows;
}Code language: TypeScript (typescript)
The return type of the server function which calls the Durable Object’s function is still this:
Promise<
{
id: number;
name: string;
createdAt: string;
}[] & Disposable
>;Code language: TypeScript (typescript)
The Durable Object result is still getting that Disposable added on. Remember, we don’t instantiate the Durable Object’s class directly; instead, we always go through this to create a proxy to the Durable Object.
const { WorkoutTemplateAIGenerationDO } = env;
const doId = WorkoutTemplateAIGenerationDO.idFromName(userId);
return WorkoutTemplateAIGenerationDO.get(doId);Code language: TypeScript (typescript)
Those utilities wrap the Durable Object and handle the network boilerplate for making requests. That’s what’s taking our actual return types and tacking on Disposable; and for that matter, wrapping return types with Promise.
Is this a problem?
Maybe, or maybe not. For this particular example, you can probably just ignore the Disposable, and everything will probably work.
This produces no errors.
const { data: aiSessions } = useSuspenseQuery(getAiSessionsQueryOptions());
const arr: SessionSummary[] = aiSessions;Code language: TypeScript (typescript)
And aiSessions is of type:
{
id: number;
createdAt: string;
name: string;
}[] & Disposable;Code language: TypeScript (typescript)
But thanks to the way TypeScript’s structural typing works, we can absolutely assign that to SessionSummary[]; the Disposable part is just ignored.
But let’s take a look at a different example.
When the Added-On Disposable Type Gets in the Way
Have a look at this type
export type SessionPayload =
| { status: "not-found" }
| { status: "error" }
| {
status: "loaded";
session: typeof session.$inferSelect;
prompts: PromptPayload[];
};Code language: TypeScript (typescript)
This Durable Object method that returns that type:
loadSession(sessionId: number): SessionPayload {
try {
const session = this.db.select().from(sessionTable).where(eq(sessionTable.id, sessionId)).get();
if (!session) {
return { status: "not-found" };
}
const promptsRaw = this.#queryPrompts(eq(sessionPromptTable.sessionId, sessionId)).all();
const prompts: PromptPayload[] = promptsRaw.map(payload =>
this.#transformQueriedPromptResult(sessionId, payload),
);
return { status: "loaded", session: session, prompts };
} catch (error) {
return { status: "error" };
}
}Code language: TypeScript (typescript)
Simply calling and returning this method from a TanStack server function:
export const loadAiSessionServerFn = createServerFn({ method: "POST" })
.validator((payload: { sessionId: number }) => payload)
.middleware([withWtDo])
.handler(async ({ data, context }) => {
return context.wtDo.loadSession(data.sessionId);
});Code language: TypeScript (typescript)
Produces a horrendous TypeScript error:
Argument of type '({ data, context }: ServerFnCtx<Register, "POST", readonly [FunctionMiddlewareAfterServer<Register, unknown, undefined, { wtDo: DurableObjectStub<WorkoutTemplateAIGenerationDO>; }, undefined, undefined, undefined>], (payload: { ...; }) => { ...; }>) => Promise<...>' is not assignable to parameter of type 'ServerFn<Register, "POST", readonly [FunctionMiddlewareAfterServer<Register, unknown, undefined, { wtDo: DurableObjectStub<WorkoutTemplateAIGenerationDO>; }, undefined, undefined, undefined>], (payload: { ...; }) => { ...; }, Promise<...>, true>'.
Type 'Promise<({ status: "not-found"; } & Disposable) | ({ status: "error"; } & Disposable) | ({ status: "loaded"; session: { id: number; createdAt: string; name: string; }; prompts: { ...; }[]; } & Disposable)>' is not assignable to type 'Promise<ValidateSerializableMapped<{ status: "not-found"; } & Disposable, RegisteredSerializableInput<Register>> | ValidateSerializableMapped<...> | ValidateSerializableMapped<...>>'.
Type '({ status: "not-found"; } & Disposable) | ({ status: "error"; } & Disposable) | ({ status: "loaded"; session: { id: number; createdAt: string; name: string; }; prompts: { ...; }[]; } & Disposable)' is not assignable to type 'ValidateSerializableMapped<{ status: "not-found"; } & Disposable, RegisteredSerializableInput<Register>> | ValidateSerializableMapped<...> | ValidateSerializableMapped<...>'.
Type '{ status: "not-found"; } & Disposable' is not assignable to type 'ValidateSerializableMapped<{ status: "not-found"; } & Disposable, RegisteredSerializableInput<Register>> | ValidateSerializableMapped<...> | ValidateSerializableMapped<...>'.
Type '{ status: "not-found"; } & Disposable' is not assignable to type 'ValidateSerializableMapped<{ status: "not-found"; } & Disposable, RegisteredSerializableInput<Register>>'.
Types of property '[Symbol.dispose]' are incompatible.
Type '() => void' is not assignable to type 'SerializationError<"Function may not be serializable">'.Code language: plaintext (plaintext)
I’ll be honest, I’m not even sure why this error happened. I suspect the Union type (my SessionPayload) being intersected with the Disposable type produced something slightly unexpected, and incompatible with TanStack’s serialization checks. But more importantly, I don’t care exactly why this broke. The Disposable type is actually adding friction now, so let’s just get rid of it.
Attempt 1
You might think something like this would be a nifty helper
export function doStrip<T>(value: T): Omit<T, typeof Symbol.dispose> {
return value;
}Code language: TypeScript (typescript)
Take in some type, and just strip off the undesired pieces. You pass the value through this function to “clean” the type of Symbol.dispose (which is what the Disposable type adds).
When we do this:
export const loadAiSessionServerFn = createServerFn({ method: "POST" })
.validator((payload: { sessionId: number }) => payload)
.middleware([withWtDo])
.handler(async ({ data, context }) => {
return doStrip(await context.wtDo.loadSession(data.sessionId));
});Code language: TypeScript (typescript)
The return type is now inferred as:
Omit<
| ({
status: "not-found";
} & Disposable)
| ({
status: "error";
} & Disposable)
| ({
status: "loaded";
session: {
id: number;
createdAt: string;
name: string;
};
prompts: {
//.......
};
} & Disposable),
typeof Symbol.dispose
>;Code language: TypeScript (typescript)
Which is… not at all what we wanted.
The problem is, Omit<T, typeof Symbol.dispose> doesn’t distribute over the union type that we saw before.
But TypeScript has a special type for which distributing over unions is a core feature: conditional types.
Attempt 2
Once we realize conditional types are how we distribute over unions, we might try something like this:
type StripDisposable<T> = T extends unknown ? Omit<T, typeof Symbol.dispose> : never;
export function doStrip<T>(value: T): StripDisposable<T> {
return value as StripDisposable<T>;
}Code language: TypeScript (typescript)
Then use it like this:
export const loadAiSessionServerFn = createServerFn({ method: "POST" })
.validator((payload: { sessionId: number }) => payload)
.middleware([withWtDo])
.handler(async ({ data, context }) => {
return doStrip(await context.wtDo.loadSession(data.sessionId));
});Code language: TypeScript (typescript)
This works, essentially. Unfortunately, the type is reported as:
Omit<
{
status: "not-found";
} & Disposable,
typeof Symbol.dispose
> |
Omit<
{
status: "error";
} & Disposable,
typeof Symbol.dispose
> |
Omit<
{
status: "loaded";
session: {
id: number;
createdAt: string;
name: string;
};
prompts: {};
} & Disposable,
typeof Symbol.dispose
>;Code language: TypeScript (typescript)
It’s ugly but correct. But let’s take a step back.
The Real Solution
The real solution is to add a return type to your server function.
Normally, with TypeScript, relying on type inference is perfectly acceptable, and frankly preferred most of the time. But here, we simply specify the return type we want:
export const loadAiSessionServerFn = createServerFn({ method: "POST" })
.validator((payload: { sessionId: number }) => payload)
.middleware([withWtDo])
.handler(async ({ data, context }): Promise<SessionPayload> => {
return context.wtDo.loadSession(data.sessionId);
});Code language: TypeScript (typescript)
And that’s that. The Durable Object still returns a Disposable type. But that value, with the Disposable cruft, is still assignable to our return type, and anything calling into our server function will now get back solely our declared return type.
Exactly what we want.
Parting Thoughts
Hopefully this post contained some useful tidbits for using Durable Objects effectively with TanStack Start.