DEV Community

Cover image for How to build your own Javascript-native RPC framework
Nadeesha Cabral
Nadeesha Cabral

Posted on • Edited on

How to build your own Javascript-native RPC framework

Why? Well for fun of course. But more often than once, my "just for fun" code has ended up in production. Rather than warn people against doing the same thing, I've taken it upon myself to sow further chaos.

Let's get into it.

RPC?

RPC is a protocol that one program can use to request a service from a program located in another computer on a network. The client makes a procedure call that appears to be local but is actually run on a remote machine.

In other words, it looks like you're executing the function locally, but it executes remotely.

We will

  1. Build a server that will house remote procedures.
  2. Use JSON as a serialisation mechanism.
  3. Create a client that'll handle serialisation and deserialisation.

Let's build a small server

To achieve the R (Remote) part of RPC, we'll build a small server. Nothing fancy.

// server.ts

const { createServer } = require("http");

const functions = {
  sum: (a: number, b: number) => a + b,
  wait: async (delay: number) => {
    await new Promise((resolve) => setTimeout(resolve, delay * 1000));
    return "Done!";
  },
};

createServer((req, res) => {
  if (req.method === "POST" && req.url === "/invoke") {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", async () => {
      const { fn, args } = JSON.parse(body);
      const result = await functions[fn](...(args || []));
      res.end(JSON.stringify(result));
    });
  } else {
    res.statusCode = 404;
    res.end("Page not found!"x);
  }
}).listen(8080);
Enter fullscreen mode Exit fullscreen mode

Theoretically, you can invoke any function that you have on the server side with a cURL, and get the output:

curl -X POST -d '{"fn":"sum","args":[1,2]}' http://localhost:8080/invoke
> 3
Enter fullscreen mode Exit fullscreen mode

When we receive something on the /invoke endpoint, we'll deconstruct the body to figure out the function name and the args, and execute the functions from list of functions we've got. (Nevermind the type safety!)

Creating the client

Our RPC client would looks something like this:

// client.ts

async function invoke(fn: string, args: any[]): Promise<any> {
  try {
    const response = await fetch("http://localhost:8080/invoke", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ fn, args }),
    });

    if (!response.ok) {
      throw new Error(`Server responded with status: ${response.status}`);
    }

    return await response.json();
  } catch (error) {
    console.error("Error invoking function:", error);
    throw error;
  }
}

// Example usage:

(async () => {
  try {
    const sumResult = await invoke("sum", [5, 3]);
    console.log("Sum result:", sumResult); // Output will be 8

    const waitResult = await invoke("wait", [2]);
    console.log("Wait result:", waitResult); // Output will be "Done!" after 2 seconds
  } catch (error) {
    console.error("Error in function invocation:", error);
  }
})();
Enter fullscreen mode Exit fullscreen mode

And we'll have a invoke function which will execute any function on the server end.

The client is responsible for serialising and deserialising the RPC call and result.

Now when you want to add a new function foo, you'd just add it to the functions collection in the server, and invoke('foo') on the client.

What we're missing so far

Of course, we're missing a few things.

  • Guarantees: There's no published contract from the server. Client has no idea whether foo() exists on the server, and if it exists - what parameters it accepts.
  • Timeouts: If your HTTP timeout is 10 seconds and if you call delay(20), you won't get your result back. In this case, you'd need to change the architecture of the server to enable asynchronous communication.

Adding stronger guarantees

Let's discuss adding in the guarantees - or rather, the tradeoffs involved.

If you want your server contract to be strongly adhered to, then you'll have to publish the contract to the client somehow.

gRPC uses Protocol Buffers to define structured data. Typescript native tRPC uses zod. But all in all, you'd have some kind of a service definition.

However, a service definition is not necessarily free.

If you're using gRPC, you'd need a compile step to generate the Protobuf definitions. tRPC is more flexible, but you'll end up with a duplicative zod definition for everything.

If you have a monolithic codebase, then you can completely do away with a separate service definition because you can use your function signature (if it's typed - of course) as the service definition.

Allowing for long running functions

We can modify the server to have two endpoints, /invoke and /result.

  • Invoke Endpoint: When a function is invoked via this endpoint, the server will generate a unique ID for the request, start executing the function asynchronously, and immediately return the ID to the client.

  • Result Endpoint: The client can use this endpoint to poll for the result of the execution using the provided ID.

Here's the changed server:

// server.ts

const { createServer } = require("http");
const { v4: uuidv4 } = require('uuid');

const functions = {
  sum: (a: number, b: number) => a + b,
  wait: async (delay: number) => {
    await new Promise((resolve) => setTimeout(resolve, delay * 1000));
    return "Done!";
  },
};

const results = new Map();

createServer((req, res) => {
  if (req.method === "POST" && req.url === "/invoke") {
    let body = "";
    req.on("data", (chunk) => (body += chunk));
    req.on("end", async () => {
      const { fn, args } = JSON.parse(body);
      const id = uuidv4();
      results.set(id, { status: "pending", result: null });

      functions[fn](...(args || [])).then(result => {
        results.set(id, { status: "completed", result });
      }).catch(error => {
        results.set(id, { status: "error", result: error.message });
      });

      res.end(JSON.stringify({ id }));
    });
  } else if (req.method === "GET" && req.url.startsWith("/result/")) {
    const id = req.url.split('/')[2];
    const result = results.get(id);

    if (result) {
      res.end(JSON.stringify(result));
    } else {
      res.statusCode = 404;
      res.end("Result not found!");
    }
  } else {
    res.statusCode = 404;
    res.end("Page not found!");
  }
}).listen(8080);
Enter fullscreen mode Exit fullscreen mode

And we'll have to change the client as well to poll until it gets a non-pending state. (We'll ignore the indefinitely pending state for now)

// client.ts

async function invoke(fn: string, args: any[]): Promise<any> {
  const response = await fetch("http://localhost:8080/invoke", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ fn, args }),
  });

  if (!response.ok) {
    throw new Error(`Server responded with status: ${response.status}`);
  }

  const { id } = await response.json();

  return await pollForResult(id);
}

async function getResult(id: string): Promise<any> {
  const response = await fetch(`http://localhost:8080/result/${id}`);

  if (!response.ok) {
    throw new Error(`Server responded with status: ${response.status}`);
  }

  return await response.json();
}

async function pollForResult(id: string): Promise<any> {
  let result = await getResult(id);

  while (result.status === 'pending') {
    await new Promise(resolve => setTimeout(resolve, 1000)); // Poll every second
    result = await getResult(id);
  }

  return result.result;
}

// Example usage:

(async () => {
  try {
    const sumResult = await invoke("sum", [5, 3]);
    console.log("Sum result:", sumResult); // Output will be 8 after processing

    const waitResult = await invoke("wait", [2]);
    console.log("Wait result:", waitResult); // Output will be "Done!" after 2 seconds
  } catch (error) {
    console.error("Error in function invocation:", error);
  }
})();
Enter fullscreen mode Exit fullscreen mode

This will allow us to execute more RPC calls without keeping a lot of connections open for a long time.

Conclusion

Still, there are a few rough edges to make this a truly Javascript-native RPC framework.

  • What happens on a promise rejection? We don't really serialise the error well enough for the caller to intercept it.
  • A process crashing while processing might make the enqueued functions disappear. Putting that on a persisted data store might make it be pending forever.
  • Retrying: Can we retry the function again if it fails? It might not be safe to retry everything in bulk.

I might cover these areas in a future post.


If you enjoyed this post, you might find Differential interesting. It allows you to keep your Javascript code in one single codebase, but execute it in a distributed way using RPCs defined by Javascript functions and Javascript functions alone. If you use Typescript, you get type-safe service definitions without any extra work :)

Top comments (0)