WebSocket Adapter Internals
This document provides a deep dive into how the custom tRPC WebSocket adapter processes messages on the Cloudflare Worker.
Overview
Since Cloudflare Workers don't have a native tRPC WebSocket adapter, we use a custom implementation (ws-adapter.ts) that translates standard JSON-RPC messages into tRPC procedure calls.
Message Flow
Detailed Implementation Breakdown
1. JSON-RPC Request Format
The adapter expects a standard JSON-RPC 2.0-like structure:
export interface JSONRPCRequest {
id: number | string; // Unique request ID
method: 'query' | 'mutation' | 'subscription'; // tRPC operation type
params: {
path: string; // Procedure path (e.g., "sync.pull")
input?: unknown; // Procedure input data
};
}
2. Creating a Server-Side Caller
The most critical part of the adapter is router.createCaller(ctx).
// Create a caller instance with the provided context (env, db, etc.)
const caller = router.createCaller(ctx);
Why do we use a caller?
A tRPC "caller" is a generated object where every procedure is mapped to a standard asynchronous function. This allows us to execute procedures programmatically on the server as if they were local functions, while automatically applying the context (ctx) to every call.
3. Dynamic Path Resolution (Traversal)
tRPC routers can be deeply nested (e.g., router.sync.pull). The incoming request provides a string path like "sync.pull". The adapter must traverse the caller object to find the target function:
const parts = msg.params.path.split('.'); // ["sync", "pull"]
let fn: any = caller;
for (const part of parts) {
fn = fn?.[part]; // Navigate from caller -> sync -> pull
}
// Verification
if (typeof fn !== 'function') {
throw new TRPCError({ code: 'NOT_FOUND', message: `Procedure ${msg.params.path} not found` });
}
4. Direct Execution
Once the function fn is found, it is executed directly with the input from the message:
const result = await fn(msg.params.input);
Because fn was obtained from a caller initialized with ctx, the procedure logic inside trpc.ts runs with full access to ctx.env and the database.
5. Response Formatting
Finally, the result (or error) is wrapped back into a JSON-RPC response format:
return JSON.stringify({
id: msg.id,
result: {
type: 'data',
data: result === undefined ? null : result,
},
});
Standalone Example: Logic Simulation
You can test this logic independently with this standalone simulation:
import { initTRPC } from '@trpc/server';
// 1. Define a simple router
const t = initTRPC.create();
const appRouter = t.router({
user: t.router({
greet: t.procedure.query(() => "Hello from WS!"),
})
});
// 2. Simulate an incoming message
const mockMessage = {
id: 1,
params: { path: "user.greet", input: {} }
};
// 3. The Adapter Logic
async function simulateAdapter(path: string, router: any) {
const caller = router.createCaller({}); // Empty context for demo
const parts = path.split('.');
let fn: any = caller;
for (const part of parts) {
fn = fn[part];
}
return await fn();
}
// 4. Run simulation
simulateAdapter(mockMessage.params.path, appRouter).then(console.log);
// Output: "Hello from WS!"
Error Handling
If a procedure fails, the adapter catches the exception and returns a structured error:
try {
// ... execution
} catch (cause) {
const error = getTRPCErrorFromUnknown(cause);
return JSON.stringify({
id: msg.id,
error: {
message: error.message,
code: error.code,
},
});
}
This ensures that the client-side tRPC proxy receives a standard error that it can re-throw, maintaining consistent error handling across HTTP and WebSockets.