The New Transparent RPC for JavaScript
Tags: typescript, webdev, opensource, node
Published: 2023-10-23 12:30:00

**Getting data from a JavaScript server is too complicated**. I'm not saying that it's difficult but **complicated**. We have many options to do so but they all require us to rethink how we acquire that data. Let's say that I have some function defined on the server:

```typescript
function createProfile({ name, email, password, photo }) {
    // ... (implementation irrelevant)
    return newProfile.id
}
```

This function does exactly what I want. I haven't yet thought about the server, I just know that I want to create a profile and get the ID back. Let's consider how this function gets to the client.

We could set up an HTTP server but we'll need to wrap our function in a route, mapping our function to HTTP-specific verbs and errors. We could try GraphQL but we'll need to define a schema for our function in its IDL. We could try gRPC but we'll need to create protocol buffers, implement them, and generate a client. We could try tRPC but we'll need to wrap our function in its procedures and router. But even then, most of these options require special processing for files or additional setup for a type-safe client or a lot more work to support real-time events from the server.

## What's the Alternative?

None of these options are necessarily difficult and there are additional frameworks to make them easier but **they all add a lot of wrappers to the function that I want to call** when I just want to call the function itself. And this is just one function, what if I have hundreds?

If I have a server written in JavaScript and the client is JavaScript, shouldn’t I be able to call the function, like it’s JavaScript?

```typescript
createProfile({
    name: "Ted Test",
    email: "ted@example.com",
    password: "redacted :)",
    photo: new File([...], "headshot.jpg")
})
```

**That's what we're going to do today**. We'll use [an open-source library](https://github.com/doseofted/prim-rpc) called [Prim+RPC](https://github.com/doseofted/prim-rpc#readme): a thin layer between the server and client [**of your choice**](https://prim.doseofted.me/docs/reference/plugins#server-handlers) that translates function calls and results so that you can simply call your server functions on the client as intended.

> 💡 This article is a much shorter <a target="_blank" rel="noopener noreferrer nofollow" href="https://prim.doseofted.me/docs/learn/setup" style="pointer-events: none">adaptation of the setup guide on the Prim+RPC website</a>. If you'd like a more in-depth look at this project, check out the full guide.

{% cta https://github.com/doseofted/prim-rpc %} Star on GitHub {% endcta %}

## Let's Get Started!

I've set up a starter project for us, so we don't have to start from scratch. It is a monorepo composed of two modules: a Node server where our function will be defined and a client that will call the function, both serialized using [Prim+RPC's plugins for the Fetch API](https://prim.doseofted.me/docs/reference/plugins/server/server-fetch).

You can download the project with the command below or [download it directly](https://github.com/doseofted/prim-rpc-starter/tree/follow-along):

```bash
npx giget@latest "gh:doseofted/prim-rpc-starter#follow-along"
```

It's also [available and easy to get started on StackBlitz](https://stackblitz.com/github/doseofted/prim-rpc-starter/tree/follow-along):

{% embed https://stackblitz.com/github/doseofted/prim-rpc-starter/tree/follow-along %}

You can start the project by running `npm install` to install dependencies and then `npm run dev` to start both the server and client parts of the project. The client will be available at `http://localhost:3000`.

We'll be greeted with a message "Not implemented". **Let's fix that!**

## Server Setup

Let's set up the server first. First things first: let's define a function. We'll work with something simple to get started. Add this function to `server/module.ts`:

```typescript
export function sayHello(x = "Backend", y = "Frontend") {
	return `${x}, meet ${y}.`
}
sayHello.rpc = true
```

**This is just a regular JavaScript function**. The only thing that we added is a property `.rpc` that signals to Prim+RPC that we want to expose this function as RPC. Since Functions in JavaScript are also Objects, this is valid syntax (we could also omit this property and [add our function to an allow-list](https://prim.doseofted.me/docs/reference/config#-allow-list)).

Moving on to the server framework itself, replace the contents of `server/index.ts` with the following. I'll explain what this does:

```typescript
import { createPrimServer } from "@doseofted/prim-rpc"
import { primFetch } from "@doseofted/prim-rpc-plugins/server-fetch"
import { createServer } from "node:http"
import { createServerAdapter } from "@whatwg-node/server"
import * as module from "./module"

const prim = createPrimServer({ module })
function postprocess(res: Response) {
	res.headers.set("access-control-allow-origin", "http://localhost:3000")
	res.headers.set("access-control-allow-headers", "content-type")
}
const fetch = primFetch({ prim, postprocess })

const fetchAdapter = createServerAdapter(fetch)
createServer(fetchAdapter).listen(3001)
console.log("Prim+RPC is available at http://localhost:3001/prim")

export type Module = typeof module
```

First, we pass the `module` containing our function to `createPrimServer()`: a framework-agnostic utility to receive RPC and respond with RPC results. We then pass the Prim+RPC server to a [method handler](https://prim.doseofted.me/docs/reference/create#method-handler), `primFetch()`, which [wraps our RPC into Fetch API Request and Response objects](https://prim.doseofted.me/docs/reference/plugins/server/server-fetch). We also add two necessary CORS headers on every Request so that our client in the browser is allowed to request resources from the server.

> 💡 If you are running this example from Stackblitz, you may need to change the <code>access-control-allow-origin</code> header value to <code>https://localhost:3000</code> if you experience any issues running the project (using secure HTTP).

The returned `fetch` utility is a function that takes a Request and returns a Response, as expected by server frameworks like Deno and Bun. Since Node doesn't yet support this natively, we use an adapter, `createServerAdapter()`, for Node's `createServer` utility. Now we can expose our function over HTTP, using port 3000. As a last step, we export the type of our `module` which will later become a [type import](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-8.html#type-only-imports-and-export) on the client (exposing types only, not the actual code).

We did it. **We're ready to move on to the client**. As a sanity check, we can test this out now by sending a simple request. With the server running, try out this command :

```bash
curl "http://localhost:3001/prim/sayHello?0=Backend&1=Terminal"
```

Our Prim+RPC server is now available at `http://localhost:3000/prim` (you can also [change the path prefix](https://prim.doseofted.me/docs/reference/config#-prefix) if you'd like).

## Client Setup

The client is very easy to set up. Replace the contents of `client/prim.ts` with the following. I'll explain what this does:

```typescript
import { createPrimClient } from "@doseofted/prim-rpc"
import { createMethodPlugin } from "@doseofted/prim-rpc-plugins/browser-fetch"
import type { Module } from "../server"

export const client = createPrimClient<Module>({
	endpoint: "http://localhost:3001/prim",
	methodPlugin: createMethodPlugin(),
})
export default client
```

We have set up the Prim+RPC client, a framework-agnostic tool for sending RPC and receiving RPC results. We pass a [type parameter](https://www.typescriptlang.org/docs/handbook/2/generics.html#hello-world-of-generics) to our client with the type of our module: the Prim+RPC client will apply RPC-specific transformations to our types. Note that we don't pass the module code itself, only the TypeScript types. We then pass the server address to the client as well as a [method plugin](https://prim.doseofted.me/docs/reference/create#method-plugin) that will [turn our function calls into Requests for the Fetch API](https://prim.doseofted.me/docs/reference/plugins/client/browser-fetch). We export this client to be used throughout our app.

That's all there is to it. We can now use this client to call our function. Replace the contents of `client/index.ts` with the following:

```typescript
import { client } from "./prim"

const greeting = await client.sayHello()
console.log(greeting) // "Frontend, meet Backend."

const app = document.getElementById("app")
if (app) app.innerText = greeting
```

We didn't need to generate code for a client or wrap our function call in some request specific to the client, we just called the function and received a result.

Since our server and client are in two separate environments and it may take a (very short) amount of time to resolve, our result is wrapped in a Promise so as not to block execution on the main thread (which means we `await` the result). But the Prim+RPC client automatically transforms our `Module` types for us, so we know.

## That Was Easy

Now, we can add more functions to the server and **we don't need any more setup to call those functions** on the client. It's just ready to be called.

We could stop here. **But we can do much more with Prim+RPC**: we can support file uploads and downloads, additional types, add validation, and even pass server context to our functions when needed.

Read on to learn how we can use these features.

> ⭐ <a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/doseofted/prim-rpc" style="pointer-events: none">Prim+RPC is an open-source project</a>, <strong>currently in prerelease</strong>. If you find the library useful, consider <a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/doseofted/prim-rpc" style="pointer-events: none"><strong>giving it a star on GitHub</strong></a><strong> </strong>and sharing it with others<strong>.</strong>

{% cta https://github.com/doseofted/prim-rpc %} Star on GitHub {% endcta %}

## Adding Validation

When calling a function over the Internet, we don't know what could be passed to the server. Prim+RPC doesn't validate arguments by default but we can add this easily. Let's validate using Zod as an example. First, we'll install it on the server:

```bash
cd server
npm add zod
```

Let's modify our `server/module.ts` so we validate and overwrite the given arguments:

```typescript
import { z } from "zod"
 
export function sayHello(x = "Backend", y = "Frontend") {
	;[x, y] = z.tuple([z.string(), z.string()]).parse([x, y])
	return `${x}, meet ${y}.`
}
sayHello.rpc = true
```

And now we have validation. If you'd like to ensure functions are never defined without validation, [check out Prim+RPC's security guide](https://prim.doseofted.me/docs/learn/security#validate-arguments) for more details.

## File Support

Prim+RPC can support files as arguments and return them to the caller. With the Fetch plugin, we don't even have to do any extra work. **It works out of the box**! Let's demonstrate this with an example that converts a Markdown file to HTML, using [micromark](https://github.com/micromark/micromark#readme). First, we'll install it on the server:

```bash
cd server
npm add micromark
```

And replace `server/module.ts` with the following:

```typescript
import { micromark } from "micromark"
import { File } from "node:buffer"
 
export async function markdownToHtml(markdownFile: File | string) {
	const markdown = typeof markdownFile === "string" ? markdownFile : await markdownFile.text()
	const html = micromark(markdown)
	return new File([html], "snippet.html", { type: "text/html" })
}
markdownToHtml.rpc = true
```

On the client, we just call it. Update `client/index.ts`:

```typescript
import { client } from "./prim"
 
const markdown = "[**Backend**, meet **Frontend**.](https://prim.doseofted.me/)"
const htmlFile = await client.markdownToHtml(markdown)
console.log(htmlFile.name, htmlFile instanceof File)
 
const app = document.getElementById("app")
if (app) app.innerHTML = await htmlFile.text()
```

We could also access returned files from a URL, for easy file downloads. Try the following command or visit the given URL in your web browser:

```bash
curl "http://localhost:3001/prim/markdownToHtml?0=_Hello%20there_"
```

## Extended Types

Prim+RPC, by default, can support arguments and return values serializable by JSON (with files processed separately). But what if we want more? Libraries like [superjson](https://github.com/blitz-js/superjson#readme) can add support for additional types like Map, Set, Date, BigInt, and more. And Prim+RPC supports custom serialization like superjson! Let's install it in our project:

```bash
cd server
npm add superjson
cd ../client
npm add superjson
```

Now we can change the `.jsonHandler` option in `server/index.ts`. Replace the contents of this file with the following:

```typescript
import { createPrimServer } from "@doseofted/prim-rpc"
import { primFetch } from "@doseofted/prim-rpc-plugins/server-fetch"
import * as module from "./module"
import { createServer } from "node:http"
import { createServerAdapter } from "@whatwg-node/server"
import jsonHandler from "superjson"

const prim = createPrimServer({ module, jsonHandler })
function postprocess(res: Response) {
	res.headers.set("access-control-allow-origin", "http://localhost:3000")
	res.headers.set("access-control-allow-headers", "content-type")
}
const fetch = primFetch({ prim, postprocess })
 
const fetchAdapter = createServerAdapter(fetch)
createServer(fetchAdapter).listen(3001)
console.log("Prim+RPC is available at http://localhost:3001/prim")
 
export type Module = typeof module
```

Serialization and deserialization go hand-in-hand so let's add this to the client too. Replace the contents of `client/prim.ts` with the following:

```typescript
import { createPrimClient } from "@doseofted/prim-rpc"
import { createMethodPlugin } from "@doseofted/prim-rpc-plugins/browser-fetch"
import type { Module } from "../server"
import jsonHandler from "superjson"

export const client = createPrimClient<Module>({
	endpoint: "http://localhost:3001/prim",
	methodPlugin: createMethodPlugin(),
	jsonHandler,
})
export default client
```

Now can create functions that use these new supported types. Let's try it out. Replace `server/module.ts` with:

```typescript
export function whatIsDayAfter(day: Date) {
	return new Date(day.valueOf() + 1000 * 60 * 60 * 24)
}
whatIsDayAfter.rpc = true
```

And call it in `client/index.ts`:

```typescript
import { client } from "./prim"
 
const tomorrow = await client.whatIsDayAfter(new Date())
console.log(tomorrow, tomorrow instanceof Date)
 
const app = document.getElementById("app")
if (app) app.innerText = tomorrow.toDateString()
```

See the [JSON Handler documentation](https://prim.doseofted.me/docs/reference/plugins/additional/json-handlers) to learn how to set up other popular JSON alternatives.

## Support Callbacks

Prim+RPC can support callbacks on your function as well. We just need to set up a [callback handler](https://prim.doseofted.me/docs/reference/create#callback-handler). While functions return once, callbacks on a function can fire multiple times so we need to maintain a connection to the server. For this, we'll use a [callback handler backed by a WebSocket connection](https://prim.doseofted.me/docs/reference/plugins/server/ws).

First, let's install `ws` which will act as our WebSocket server:

```bash
cd server
npm add ws
npm add -D @types/ws
```

Replace `server/index.ts` with the following. **And stick with me**: this is one-time setup and we don't have to touch WebSockets again after this:

```typescript
import { createPrimServer } from "@doseofted/prim-rpc"
import { primFetch } from "@doseofted/prim-rpc-plugins/server-fetch"
import { createCallbackHandler } from "@doseofted/prim-rpc-plugins/ws"
import * as module from "./module"
import { createServer } from "node:http"
import { createServerAdapter } from "@whatwg-node/server"
import { WebSocketServer } from "ws"
import jsonHandler from "superjson"
 
const wss = new WebSocketServer({ noServer: true })
const callbackHandler = createCallbackHandler({ wss })
const prim = createPrimServer({ module, jsonHandler, callbackHandler })
function postprocess(res: Response) {
	res.headers.set("access-control-allow-origin", "http://localhost:3000")
	res.headers.set("access-control-allow-headers", "content-type")
}
const fetch = primFetch({ prim, postprocess })
 
const fetchAdapter = createServerAdapter(fetch)
const server = createServer(fetchAdapter).listen(3001)
console.log("Prim+RPC is available at http://localhost:3001/prim")
 
server.on("upgrade", (request, socket, head) => {
	wss.handleUpgrade(request, socket, head, ws => {
		wss.emit("connection", ws, request)
	})
})
 
export type Module = typeof module
```

Are you still with me? We have just set up a WebSocket connection and passed that WebSocket server to Prim+RPC. **Now the server can handle callbacks on your functions!**

However, the client doesn't yet know how to tell the server that it has callbacks. Let's do that now using the [WebSocket callback plugin](https://prim.doseofted.me/docs/reference/plugins/client/browser-websocket):

```typescript
import { createPrimClient } from "@doseofted/prim-rpc"
import { createMethodPlugin } from "@doseofted/prim-rpc-plugins/browser-fetch"
import { createCallbackPlugin } from "@doseofted/prim-rpc-plugins/browser-websocket"
import type { Module } from "../server"
import jsonHandler from "superjson"
 
export const client = createPrimClient<Module>({
	endpoint: "http://localhost:3001/prim",
	methodPlugin: createMethodPlugin(),
	callbackPlugin: createCallbackPlugin(),
	jsonHandler,
})
export default client
```

And now we can use callbacks on our functions. Let's create a function that uses them in `server/module.ts`:

```typescript
export function typeMessage(message: string, typed: (letter: string) => void) {
	let timeout = 0
	const letters = message.split("")
	for (const letter of letters) {
		setTimeout(() => typed(letter), ++timeout * 300)
	}
}
typeMessage.rpc = true
```

This will type the message that we give it, one-by-one. Not a great use of server resources but a good demo. Let's try this on the client (`client/index.ts`):

```typescript
import { client } from "./prim"
 
const app = document.getElementById("app")
if (app) {
	client.typeMessage("Hello!", letter => {
		app.innerText += letter
	})
}
```

And now we see the message typed out on the page!

## Pass Server Context

Up to this point, we have not really touched the server inside of our functions. Instead, we've passed everything that our function needs as arguments. However, we can't always just ignore the server: it contains information that we may need that doesn't have direct comparisons to calling a function in JavaScript. But we can still access the server context from our functions. And that's what we're going to do next.

In this example, we’ll set a secret cookie from the client that is required to access our secret function. Without the cookie: no function access. However, our function won’t have to touch the cookie at all.

First, let's install a tiny helper package:

```bash
cd server
npm add cookie
npm add -D @types/cookie
```

And now we can use this package on our server. We'll define a `contextTransform` option that will be given the Request object (from the Fetch API) for every function call that we make to the server. The returned value from this function will become the context (`this` value) of our function.

Replace `server/index.ts` with the following:

> 💡 If you are running this example from Stackblitz, you will need to change the <code>access-control-allow-origin</code> header value to <code>https://localhost:3000</code> for this example to work in the browser (using secure HTTP).

```typescript
import { createPrimServer } from "@doseofted/prim-rpc"
import { primFetch } from "@doseofted/prim-rpc-plugins/server-fetch"
import { createCallbackHandler } from "@doseofted/prim-rpc-plugins/ws"
import * as module from "./module"
import { createServer } from "node:http"
import { createServerAdapter } from "@whatwg-node/server"
import { WebSocketServer } from "ws"
import jsonHandler from "superjson"
import { parse, serialize } from "cookie"
 
const cookieOpts = { httpOnly: true, sameSite: "none", secure: true } as const
function contextTransform(req: Request, res?: { headers: Headers }) {
	const secret = "can't-touch-this"
	return {
		setSecret(given: string) {
			res?.headers.set("set-cookie", serialize("secret", given, cookieOpts))
		},
		get allowed() {
			return secret === parse(req.headers.get("cookie") ?? "").secret
		},
	}
}
export type ServerContext = ReturnType<typeof contextTransform>
 
const wss = new WebSocketServer({ noServer: true })
const callbackHandler = createCallbackHandler({ wss })
const prim = createPrimServer({ module, jsonHandler, callbackHandler })
function postprocess(res: Response) {
	res.headers.set("access-control-allow-origin", "http://localhost:3000")
	res.headers.set("access-control-allow-headers", "content-type")
    res.headers.set("access-control-allow-credentials", "true")
}
const fetch = primFetch({ prim, postprocess, contextTransform })
 
const fetchAdapter = createServerAdapter(fetch)
const server = createServer(fetchAdapter).listen(3001)
console.log("Prim+RPC is available at http://localhost:3001/prim")
 
server.on("upgrade", (request, socket, head) => {
	wss.handleUpgrade(request, socket, head, ws => {
		wss.emit("connection", ws, request)
	})
})
 
export type Module = typeof module
```

Inside of the contextTransform function, we return `setSecret()` and `allowed` which will be used from our function. We have also updated the CORS headers to allow credentials so that the browser can utilize the cookies we set. Using TypeScript, we get that return type and assign it a type `ServerContext`. We'll pass this as a type hint to our function.

> ❗ This is of course a demo. In a real application, you will want to use some form of cryptography.

Replace `server/module.ts` with the following:

```typescript
import type { ServerContext } from "./index"
 
export function secretMessage(this: ServerContext, secret?: string) {
	if (secret) {
		this.setSecret(secret)
		return ""
	}
	if (!this.allowed) throw new Error("No secret, no entry.")
	return "Access granted! The answer is 42."
}
secretMessage.rpc = true
```

Notice that we only accessed the functions that we defined in the server's context so we didn't even have to touch the cookie at all. The `this` parameter [is only a type hint](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-0.html#specifying-the-type-of-this-for-functions) and is not an argument that we have to pass: Prim+RPC will handle that for us.

Now we can make one small change to the client. Since we're passing cookies down to the client, we must set the `credentials` [**option of the fetch function**](https://developer.mozilla.org/en-US/docs/Web/API/fetch#include) to “include” so that cookies can be set properly.

Let's do that real quick in `client/prim.ts`:

```typescript
import { createPrimClient } from "@doseofted/prim-rpc"
import { createMethodPlugin } from "@doseofted/prim-rpc-plugins/browser-fetch"
import { createCallbackPlugin } from "@doseofted/prim-rpc-plugins/browser-websocket"
import type { Module } from "../server"
import jsonHandler from "superjson"
 
export const client = createPrimClient<Module>({
	endpoint: "http://localhost:3001/prim",
	methodPlugin: createMethodPlugin({ credentials: "include" }),
	callbackPlugin: createCallbackPlugin(),
	jsonHandler,
})
export default client
```

And now we can call our function in `client/index.ts`:

```typescript
import { client } from "./prim"
 
await client.secretMessage("can't-touch-this")
 
const app = document.getElementById("app")
if (app) app.innerText = await client.secretMessage()
```

Since we have set the secret in a cookie, we don't technically need to set the cookie anymore. If we just comment out that line:

```typescript
import { client } from "./prim"
 
// await client.secretMessage("can't-touch-this")
 
const app = document.getElementById("app")
if (app) app.innerText = await client.secretMessage()
```

You'll find that we can still see the secret because we had already set the secret before and no longer need to pass any arguments to our function. It's handled in the server context!

This can be a powerful tool for setting up authentication, adding redirects, or otherwise integrating with the server of your choice.

## Summary

All of the frameworks that I mentioned in the beginning have their place. But when I'm getting started on a new project, how messages get passed from server to client is far from the first thing on my mind. It's important but it shouldn't dictate the logic happening on my server or restrict what I want to do.

Prim+RPC provides [a powerful library](https://github.com/doseofted/prim-rpc) for calling functions remotely and remains invisible once it is set up. I can define a function on the [server of my choice](https://prim.doseofted.me/docs/reference/plugins#server-handlers) and call that function on the client, all using JavaScript. Yet requests are [simple enough to make outside of JavaScript as well](https://prim.doseofted.me/docs/reference/structure).

> ⭐ <strong>Thank you for following along with me</strong>. If you like this method of retrieving data from the server, <a target="_blank" rel="noopener noreferrer nofollow" href="https://github.com/doseofted/prim-rpc" style="pointer-events: none">consider giving Prim+RPC a star on GitHub</a>.

{% cta https://github.com/doseofted/prim-rpc %} Star on GitHub {% endcta %}

Try adding your own functions to this project, maybe build out a simple API. If you get stuck, [there are several examples](https://prim.doseofted.me/docs/learn/examples) on the Prim+RPC website as well as this post.