The Docs.

Prim+RPC is in prerelease mode and may be unstable until official release.

Advanced Setup

This guide builds on the basic setup guide and will use the same starter project.

Add Validation

Prim+RPC validates RPC sent to the server but does not validate arguments and return types by default: this is up to the developer. When using Prim+RPC over the internet it is important to validate user input. We can validate data in our functions easily though.

We can use any validation framework that we like but, for example, we’ll choose a popular framework: Zod. Let’s install it on the server:

cd server
pnpm add zod

Now let’s modify our function on the server to overwrite and validate our arguments:

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

Now we can ensure that our arguments are strings. We could also validate the return value, use multiple validation libraries, or skip validation if needed. Since validation is part of function logic it is up to you.

There are several options to add validation to Prim+RPC, not mentioned here. See the Security guide to learn more.

File Support

Prim+RPC supports passing Files and Blobs as part of a function’s arguments and returning Files and Blobs as a function result. With the Fetch API, we don’t even need to set up anything additional. It works out of the box!

Let’s try this out. First, we’ll create a function that returns a File. As a demo, we’ll write a function that takes a Markdown file and returns an HTML file. We’ll use a library called micromark for this.

cd server
pnpm add micromark

Replace the sayHello() function with this:

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 = "idempotent"

If using Bun or Deno, you do not need to import File as it’s already a global.

This function takes either a string of Markdown or a Markdown file and converts it into an HTML file. We can test this out directly by just making a request to the server to retrieve the file itself:

curl "http://localhost:3001/prim/markdownToHtml?0=Hello%20there"

We can call this function from the client like so:

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()

If we open the page in the browser, we should now see our Markdown content as formatted content on the page.

Extended Types

Prim+RPC can support all types in function arguments and return values that are supported in JSON, as well as Files and Blobs (by skipping the JSON serialization step). However we may want to work with additional types like Dates, Maps, and Sets, which are not supported by the default JSON handler. That’s why Prim+RPC allows you to swap out this JSON handler with you own.

For instance, you may use superjson to support many JavaScript built-ins or devalue to support cyclical references. In fact, it doesn’t even need to be JSON. You may choose to serialize messages using yaml for readability or msgpack for its size and extended type support.

We’ll set up superjson as an example. First, let’s install the package in both server and client parts of the project.

cd server && pnpm add superjson
cd ..
cd client && pnpm add superjson

Now we can set up the handler. On the server:

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
 

And on the client:

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

That’s all there is to it! Let’s try it out with a function that accepts a Date. Replace our markdownToHtml() function from the last example with the function below:

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

And now we can call that function from the client:

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()

We can check the developer’s console or reload to the page to find that we have received a new Date. We can swap out this JSON handler with another as needed. Check out the available plugins to learn how to set up other JSON handlers.

We already can do a lot with Prim+RPC but we we can do even more with callbacks.

Support Callbacks

We can pass callbacks to our functions to receive events from the server as they happen, as opposed to polling the server manually. In Prim+RPC, we can pass callbacks as long as a callback handler is set up.

Callbacks are handled differently from methods. While methods return only once, callbacks on a method may be called multiple times, meaning we need to support multiple responses from the server. There are several ways to support this but for this guide, we will set up a WebSocket server and use it with an available callback handler for Prim+RPC.

In Node, we can use ws to add support for WebSockets. First install the package.

cd server
pnpm add ws
pnpm add -D @types/ws

Now we can configure the WebSocket server with our HTTP server, to handle upgrades to the connection. This setup may look complicated, and WebSockets can be difficult, but with Prim+RPC we only have to worry about this once.

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 { WebSocketServer } from "ws"
import jsonHandler from "superjson"
 
const wss = new WebSocketServer({ noServer: true })
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)
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
 

Let’s take a deep breath. Our WebSockets are now configured! Now let’s create a callback handler that will use this WebSocket connection. This step is much less complicated.

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
 

We’re almost ready to use callbacks. But since we created a callback handler on the server, we will need a compatible callback plugin on the client. Since we’re using WebSockets on the server, we’ll use the WebSocket callback plugin on the client:

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

Since our callback handler is using the same server, we can use the same endpoint. Prim+RPC will replace the protocol on our endpoint for us. Prim+RPC also provides a wsEndpoint option if the callback handler ever uses a separate address.

Now we’re ready to use callbacks on our methods. Let’s define one now. Replace the whatIsDayAfter() function, or sayHello() function if the Extended Types section was skipped, with the following:

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

Now we can use this callback on the client. Let’s update our client to use this new function:

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

Typing a message isn’t exactly a great use of server resources but it is a good demo. We can now open the console to see each letter of our message logged and see that message typed on our webpage.

Pass Server Context

Prim+RPC separates the method of communication between server and client from your function logic, allowing you to write framework-agnostic code. But there are times where you need data only available in your server’s context.

We can share this server context with Prim+RPC. Better yet, we can transform the server context so that our functions only receive data relevant to them.

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.

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

First, let’s install a small helper package to manage cookies on the server. This isn’t specific to Prim+RPC but will be used by our HTTP server.

cd server
pnpm add cookie
pnpm add -D @types/cookie

Now we can use this package with our server. Our primFetch method handler (and all method handlers in Prim+RPC) accept a contextTransform option that takes the server context as an argument, in this case our Request object, and returns a variable that will be bound to our function’s this context.

Let’s set this up now.

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
 

We have defined a function and a property that we want to expose to our functions: setSecret() will set the secret and allowed will return whether the secret is set to the correct value, both using cookies. We have also added a new CORS header which will allow the browser to set the cookie.

If you are running the starter project in a hosted environment like Stackblitz, you may need to adjust CORS rules to use https://localhost:3000 (using HTTPS instead of HTTP).

Note that we have defined a ServerContext interface. This will become available to our functions. We can now utilize the new interface in our functions, like so:

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

Our function can now return a message to the client, only if the correct cookie is given. Yet it doesn’t touch the actual cookie. Also note that passing this as an argument is only necessary if you are using TypeScript. It is removed from generated code and only serves as a type hint.

Passing server context in this way can be useful for swapping out implementations of a server. If we ever swap our the server in the future, our function logic doesn’t change. We simply modify the contextTransform function to match our new server.

There is one last step to perform on our Prim+RPC client. Since we are setting a cookie from the server, we must set the credentials option of the fetch function to “include” so that cookies can be set properly. we can do this easily with our method plugin:

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

Now let’s call this function from the client:

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

Because we passed the correct secret, we can see the secret message from the server. And because this secret has been set in the cookie, we don’t need to set it again. For demonstration, comment out that first line:

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

And note that we can still access the secret message because we’ve already set the secret in a cookie! So we don’t need to pass it in our function unless that cookie is removed.

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

You’ve finished the advanced setup guide!

Next Steps

There are many features available in Prim+RPC that we haven’t even touched yet. Learn about Prim+RPC’s other features in the configuration reference or one of the available examples to learn more about what can be done with Prim+RPC.

Report an Issue