Breaking Down tRPC: Build a Minimal Version from Scratch Part 1 (RPC)
Introduction
In the introductory video, I changed the input on the server, and it directly reflected what input my client can pass and what my server returns.
With this, we can avoid errors and friction between the client and server. By the end of this article, we will create type-safe communication between the client and server from scratch. We will dissect what tRPC does.
To achieve this, we need to use RPC and share TypeScript types between the server and the client.
RPC: Stands for Remote Procedure Call. It is a protocol that lets a client call a procedure on another program, usually remote as if it were a normal call. It hides the details of the remote interaction.
What is tRPC? It is an implementation of RPC in TypeScript, designed for TypeScript monorepos.
In essence, it aims to solve the same problem as GraphQL by minimizing the complexity of API communication between the client and server.
However, it is more lightweight and eliminates the need for code generation to ensure type safety.
"I built tRPC to allow people to move faster by removing the need for a traditional API layer, while still having confidence that our apps won't break as we rapidly iterate. Try it out for yourself and let us know what you think!" — Alex/KATT, Creator of tRPC
Because the type-safe part is huge, we will dive deep into it in the next article.
Simple Node Server
Let's start by creating a simple Node server. To create an RPC protocol, we need only one POST endpoint. We will pass to this endpoint information about the method we want to call: the name and the parameters.
We will call this endpoint /rpc
and add test procedure.
var http = require('http');
const requestHandler = (req, res) => {
let body = '';
req.on('data', function(chunk) {
body += chunk;
});
req.on('end', function() {
res.writeHead(200, {'Content-Type': 'application/json'});
if(req.url === '/rpc') {
try {
const data = JSON.parse(body);
if(data.procedure === 'test') {
res.end(JSON.stringify({
"jsonrpc": "2.0",
"id": 1,
"result": "Yoda!"
}));
return
}
} catch(e) {
res.end({
"error": {
"code": -32700,
"message": "Parse error"
}
})
return
}
}
res.end('Bad request\n');
});
};
const port = 8000;
http.createServer(requestHandler).listen(port);
console.log(`Server running at http://127.0.0.1:${port}/`);
To test it, we will send a request to this endpoint with the following body:
{
"procedure": "test"
}
We will get the following response:
{
"jsonrpc": "2.0",
"id": 1,
"result": "Yoda!"
}
Let's add other procedures to our server and refactor our code.
var http = require('http');
const { type } = require('os');
const router = {
getShinobi: {
type: 'query',
handler: ({ input }) => {
return {
"id": 1,
"result": "Shinobi!"
}
}
},
getSorcerer: {
type: 'query',
handler: ({ input }) => {
return {
"id": 1,
"result": "Sorcerer!"
}
}
},
addShinobi: {
type: 'mutation',
handler: ({ input }) => {
console.log("===>", input);
return {
"data": input.name
}
}
},
}
const requestHandler = (req, res) => {
let body = '';
req.on('data', function(chunk) {
body += chunk;
});
req.on('end', function() {
res.writeHead(200, {'Content-Type': 'application/json'});
if(req.method !== 'POST') {
res.end("Method not allowed");
return
}
if(req.url !== '/rpc') {
res.end("Not found");
return
}
try {
const data = JSON.parse(body);
const route = router[data.procedure];
if(!route || route.type !== data.type) {
res.end(JSON.stringify({
"error": {
"code": -100,
"message": "Procedure not found"
}
}));
return
}
res.end(JSON.stringify(route.handler({ input: data.body })));
return
} catch(e) {
res.end({
"error": {
"code": -200,
"message": "Parse error"
}
})
return
}
});
};
const port = 8000;
http.createServer(requestHandler).listen(port);
console.log(`Server running at http://127.0.0.1:${port}/`);
We can add more flexibility to the way we create the router by building procedures step by step using a builder pattern and adding some input validation with Zod.
I want to create my router like this:
const rpc = createRPC()
const router = rpc.router({
getShinobi: rpc.procedure().query(({ input }) => {
return {
"id": 1,
"result": "Shinobi!"
}
}),
getSorcerer: rpc.procedure().query(({ input }) => {
return {
"id": 1,
"result": "Sorcerer!"
}
}),
addShinobi: rpc.procedure().mutation(({ input }) => {
return {
"data": input.name
}
})
})
Procedure Builder
We create the builder by defining a Procedure class and adding methods like input, query, and mutation. Each of these methods returns the object itself, allowing us to chain them and construct the procedure step by step.
class Procedure {
type = ''
handler = () => {}
validator = null
input(validator) {
this.validator = validator
return this
}
query(handler) {
this.type = 'query'
this.handler = handler
return this
}
mutation(handler) {
this.type = 'mutation'
this.handler = handler
return this
}
}
const createRouter = (procedureMap) => {
const router = {}
Object.entries(procedureMap).forEach(([key, value]) => {
router[key] = {
type: value.type,
validator: value.validator,
handler: value.handler
}
})
return router
}
const createRPC = () => {
return {
procedure: () => new Procedure(),
router: (procedureMap) => createRouter(procedureMap)
}
}
Client Generation
The client must know the server's procedures to call them. This includes their names, types, parameters, and return values.
For this purpose, we need to generate client-side code that interacts with the server in any language we want.
To achieve this, we create an intermediary (Interface Definition Language) representation of our procedures. In our case, it's a simple JSON file.
like this:
[
{"name":"getShinobi","type":"query","input":null},
{"name":"getSorcerer","type":"query","input":null},
{"name":"addShinobi","type":"mutation","input":{"name":{"_def":{"checks":[],"typeName":"ZodString","coerce":false}}}}
]
Now we can traverse the file and generate a client in any language we want. In our case, we will generate a JS client.
import fs from 'fs'
const readDefinitions = (path) => {
const data = fs.readFileSync(path, { encoding: 'utf-8' })
return JSON.parse(data)
}
const generateJsClient = (sourcePath, destPath) => {
const definitions = readDefinitions(sourcePath)
const clientString = `
const fetchRPC = async (url, definition, input) => {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
procedure: definition.name,
type: definition.type,
body: input
})
}).then(res => res.json())
}
class RPCClient {
api = {}
constructor(url) {
this.url = url
${definitions.map((definition) => `
this.api.${definition.name} = async (input) => {
return fetchRPC(this.url, ${JSON.stringify(definition)}, input)
}
`).join('\n')}
}
}
export const createClient = (url) => {
return new RPCClient(url)
}
`
fs.writeFileSync(destPath, clientString)
}
generateJsClient('../idf.json', 'src/rpc-client.js')
Finally, we will inject this into the node_modules directory and call it from our client, a simple React app with vite.
To inject a library manually into the node_modules directory, we will generate a package.json file and a index.js file.
We will then use the fs module to create the directory in the node_modules directory and write the files to it.
Finally, we will delete the .vite directory to remove the cache.
import fs from 'fs'
const readDefinitions = (path) => {
const data = fs.readFileSync(path, { encoding: 'utf-8' })
return JSON.parse(data)
}
const generateJsClient = (sourcePath) => {
const definitions = readDefinitions(sourcePath)
const clientString = `
const fetchRPC = async (url, definition, input) => {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
procedure: definition.name,
type: definition.type,
body: input
})
}).then(res => res.json())
}
class RPCClient {
api = {}
constructor(url) {
this.url = url
${definitions.map((definition) => `
this.api.${definition.name} = async (input) => {
return fetchRPC(this.url, ${JSON.stringify(definition)}, input)
}
`).join('\n')}
}
}
const createClient = (url) => {
return new RPCClient(url)
}
module.exports = {
createClient
}
`
const packagejsonString = `
{
"name": "rpc",
"version": "1.0.0",
"main": "index.js"
}
`
fs.mkdir("node_modules/rpc", { recursive: true }, (err) => {
if(err) throw err
})
fs.writeFileSync('node_modules/rpc/index.js', clientString)
fs.writeFileSync('node_modules/rpc/package.json', packagejsonString)
if (fs.existsSync('node_modules/.vite')) {
fs.rmSync('node_modules/.vite', { recursive: true });
}
}
generateJsClient('../idf.json')
Proxy
It is working, but we generate a lot of code and we have use many hacks. We can improve this by using a proxy in front of the client and getting rid of the interface definition generation.
const fetchRPC = async (url, definition, input) => {
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
procedure: definition.name,
type: definition.type,
body: input
})
}).then(res => res.json())
}
const proxyHandler = {
get: function(target, prop) {
return function(...args) {
return fetchRPC(target.url, {
"name": prop,
"type": "query",
"input": args[0]
})
}
}
}
class RPCClient {
constructor(url) {
this.url = url
}
}
const createClient = (url) => {
return {
api: new Proxy(new RPCClient(url), proxyHandler)
}
}
export default {
createClient
}
With this no more need to generate the client and inject it into the node_modules directory manually. we can just call a method on the rpc client and the proxy will use it as the procedure name.
What's Next?
This makes the client more flexible. But, we lose info about the server. So, the user must look at the server for procedure names, parameters, and return types.
As both the client and server are collocated, we can introduce TypeScript and pass the router type from the server to the client.
In the next article, we will use the TypeScript type system to finally reach our goal.
Thanks for reading! I hope you enjoyed this article.
See the Github Repo for the code.