Skip to content
/ panrpc Public

Language-, transport- and serialization-agnostic RPC framework with remote closure support that allows exposing and calling functions on both clients and servers.

License

Notifications You must be signed in to change notification settings

pojntfx/panrpc

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Project icon

panrpc

Language-, transport- and serialization-agnostic RPC framework with remote closure support that allows exposing and calling functions on both clients and servers.

hydrun CI Go Version Go Reference npm CI npm: @pojntfx/panrpc TypeScript docs Matrix

Overview

panrpc is a flexible high-performance RPC framework designed to work in almost any environment with advanced features such as remote closures and bidirectional RPC calls.

It enables you to:

  • Transparently call and expose RPCs in many languages: Thanks to it's use of reflection, panrpc doesn't require you to learn a DSL or run a code generator. RPCs are defined and called as local functions, and its simple protocol means that multiple languages are supported and adding support for new ones is simple.
  • Work with any transport layer: Instead of being restricted to one transport layer (like TCP or WebSockets for most RPC frameworks), panrpc depends only on the semantics of a stream or a message, meaning it works over everything from TCP, WebSockets, UNIX sockets, WebRTC, Valkey/Redis, NATS and more.
  • Work with any serializer: Instead of being restricted to one serialization framework (like Protobuf or JSON for most RPC frameworks), panrpc can use any user-defined serializer that supports streaming encode/decode, such as JSON, CBOR and others.
  • Call RPCs on both clients and servers: Unlike most RPC frameworks, which only allow you to call a server's RPCs from a client, panrpc can also work with the reverse configuration (where the server calls RPCs exposed by the client) or both at the same time.
  • Pass closures to RPCs: You can transparently pass closures and callbacks to RPCs as function parameters, and they will be called by the RPC just like if it were a local function call.

Installation

Library

You can add panrpc to your Go Go project by running the following:

$ go get github.com/pojntfx/panrpc/go/...@latest

For typescript TypeScript, you can add panrpc to your project (both server-side TypeScript/Node.js and all major browser engines are supported) by running the following:

$ npm install @pojntfx/panrpc

purl Tool

In addition to the library, the CLI tool purl is also available; purl is like cURL and gRPCurl, but for panrpc: A command-line tool for interacting with panrpc servers. purl is provided in the form of static binaries.

On Linux, you can install them like so:

$ curl -L -o /tmp/purl "https://github.com/pojntfx/panrpc/releases/latest/download/purl.linux-$(uname -m)"
$ sudo install /tmp/purl /usr/local/bin

On macOS, you can use the following:

$ curl -L -o /tmp/purl "https://github.com/pojntfx/panrpc/releases/latest/download/purl.darwin-$(uname -m)"
$ sudo install /tmp/purl /usr/local/bin

On Windows, the following should work (using PowerShell as administrator):

Invoke-WebRequest https://github.com/pojntfx/panrpc/releases/latest/download/purl.windows-x86_64.exe -OutFile \Windows\System32\purl.exe

You can find binaries for more operating systems and architectures on GitHub releases.

Tutorial

Go Go

Just looking for sample code? Check out the sources for the example coffee machine server and coffee machine client/remote control.

1. Choosing a Transport and Serializer

Expand section

Start by creating a new Go module for the tutorial and installing github.com/pojntfx/panrpc/go:

$ mkdir -p panrpc-tutorial-go
$ cd panrpc-tutorial-go
$ go mod init panrpc-tutorial-go
$ go get github.com/pojntfx/panrpc/go/...@latest

The Go version of panrpc supports many transports. While common ones are TCP, WebSockets, UNIX sockets or WebRTC, anything that directly implements or can be adapted to a io.ReadWriter can be used with the panrpc LinkStream API. If you want to use a message broker like Valkey/Redis or NATS as the transport, or need more control over the wire protocol, you can use the LinkMessage API instead. For this tutorial, we'll be using WebSockets as the transport through the nhooyr.io/websocket library, which you can install like so:

$ go get nhooyr.io/websocket@latest

In addition to supporting many transports, the Go version of panrpc also supports different serializers. Common ones are JSON and CBOR, but similarly to transports anything that implements or can be adapted to a io.ReadWriter stream can be used. For this tutorial, we'll be using JSON as the serializer through the encoding/json Go standard library.

2. Creating a Server

In this tutorial we'll be creating a simple coffee machine server that simulates brewing coffee, and can be controlled by using a remote control (the coffee machine client).

Expand section

To start with implementing the coffee machine server, create a new file cmd/coffee-machine/main.go and define a basic struct with a BrewCoffee method. This method simulates brewing coffee by validating the coffee variant, checking if there is enough water available to brew the coffee, sleeping for five seconds, and returning the new water level to the remote control:

// cmd/coffee-machine/main.go

package main

import (
	"context"
	"errors"
	"log"
	"slices"
	"time"
)

type coffeeMachine struct {
	supportedVariants []string
	waterLevel        int
}

func (s *coffeeMachine) BrewCoffee(
	ctx context.Context,
	variant string,
	size int,
) (int, error) {
	if !slices.Contains(s.supportedVariants, variant) {
		return 0, errors.New("unsupported variant")
	}

	if s.waterLevel-size < 0 {
		return 0, errors.New("not enough water")
	}

	log.Println("Brewing coffee variant", variant, "in size", size, "ml")

	time.Sleep(time.Second * 5)

	s.waterLevel -= size

	return s.waterLevel, nil
}

The following limitations on which methods can be exposed as RPCs exist:

  • Methods must have context.Context as their first argument
  • Methods can not have variadic arguments
  • Methods must return either an error or a single value and an error
  • Methods must be public (private methods won't be callable as RPCs, but stay callable as regular methods)

To start turning the BrewCoffee method into an RPC, create an instance of the struct and pass it to a panrpc Registry like so:

// cmd/coffee-machine/main.go

import "github.com/pojntfx/panrpc/go/pkg/rpc"

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	service := &coffeeMachine{
		supportedVariants: []string{"latte", "americano"},
		waterLevel:        1000,
	}

	var clients atomic.Int64
	registry := rpc.NewRegistry[struct{}, json.RawMessage](
		service,

		&rpc.RegistryHooks{
			OnClientConnect: func(remoteID string) {
				log.Printf("%v remote controls connected", clients.Add(1))
			},
			OnClientDisconnect: func(remoteID string) {
				log.Printf("%v remote controls connected", clients.Add(-1))
			},
		},
	)
}

Now that we have a registry that provides our coffee machine's RPCs, we can link it to our transport (WebSockets) and serializer of choice (JSON). This requires a bit of boilerplate to upgrade from HTTP to WebSockets, so feel free to copy-and-paste this, or take a look at the examples to check out how you can set up a different transport (TCP, WebSockets, UNIX sockets etc.) and serializer (JSON, CBOR etc.) instead:

Expand boilerplate code snippet
// cmd/coffee-machine/main.go

import (
	"encoding/json"
	"net"
	"net/http"

	"github.com/pojntfx/panrpc/go/pkg/rpc"
	"nhooyr.io/websocket"
)

func main() {
  // ...

  // Create TCP listener
	lis, err := net.Listen("tcp", "127.0.0.1:1337")
	if err != nil {
		panic(err)
	}
	defer lis.Close()

	log.Println("Listening on", lis.Addr())

	// Create HTTP server from TCP listener
	if err := http.Serve(lis, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			if err := recover(); err != nil {
				w.WriteHeader(http.StatusInternalServerError)

				log.Printf("Remote control disconnected with error: %v", err)
			}
		}()

		// Upgrade from HTTP to WebSockets
		switch r.Method {
		case http.MethodGet:
			c, err := websocket.Accept(w, r, &websocket.AcceptOptions{
				OriginPatterns: []string{"*"},
			})
			if err != nil {
				panic(err)
			}

			pings := time.NewTicker(time.Second / 2)
			defer pings.Stop()

			errs := make(chan error)
			go func() {
				for range pings.C {
					if err := c.Ping(ctx); err != nil {
						errs <- err

						return
					}
				}
			}()

			conn := websocket.NetConn(ctx, c, websocket.MessageText)
			defer conn.Close()

			// Set up the streaming JSON encoder and decoder
			encoder := json.NewEncoder(conn)
			decoder := json.NewDecoder(conn)

			go func() {
				if err := registry.LinkStream(
          r.Context(),

					func(v rpc.Message[json.RawMessage]) error {
						return encoder.Encode(v)
					},
					func(v *rpc.Message[json.RawMessage]) error {
						return decoder.Decode(v)
					},

					func(v any) (json.RawMessage, error) {
						b, err := json.Marshal(v)
						if err != nil {
							return nil, err
						}

						return json.RawMessage(b), nil
					},
					func(data json.RawMessage, v any) error {
						return json.Unmarshal([]byte(data), v)
					},
				); err != nil {
					errs <- err

					return
				}
			}()

			if err := <-errs; err != nil {
				panic(err)
			}
		default:
			w.WriteHeader(http.StatusMethodNotAllowed)
		}
	})); err != nil {
		panic(err)
	}
}

Congratulations! You've created your first panrpc server. You can start it from your terminal like so:

$ go run ./cmd/coffee-machine/main.go

You should now see the following in your terminal, which means that the server is available on localhost:1337:

Listening on localhost:1337

3. Creating a Client

In order to interact with the coffee machine server, we'll now create the remote control (the coffee machine client), which will call the BrewCoffee RPC.

Expand section

To start with implementing the remote control, create a new file cmd/remote-control/main.go and define a basic struct with a placeholder method that mirrors the BrewCoffee RPC:

// cmd/remote-control/main.go

package main

import "context"

type coffeeMachine struct {
	BrewCoffee func(
		ctx context.Context,
		variant string,
		size int,
	) (int, error)
}

In order to make the BrewCoffee placeholder method do RPC calls, create an instance of the struct and pass it to a panrpc Registry like so:

// cmd/remote-control/main.go

import "github.com/pojntfx/panrpc/go/pkg/rpc"

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	defer cancel()

	var clients atomic.Int64
	registry := rpc.NewRegistry[coffeeMachine, json.RawMessage](
		&struct{}{},

		&rpc.RegistryHooks{
			OnClientConnect: func(remoteID string) {
				log.Printf("%v coffee machines connected", clients.Add(1))
			},
			OnClientDisconnect: func(remoteID string) {
				log.Printf("%v coffee machines connected", clients.Add(-1))
			},
		},
	)
}

Now that we have a registry that turns the remote control's placeholder methods into RPC calls, we can link it to our transport (WebSockets) and serializer of choice (JSON). Once again, this requires a bit of boilerplate to connect to the WebSocket, so feel free to copy-and-paste this, or take a look at the examples to check out how you can set up a different transport (TCP, WebSockets, UNIX sockets etc.) and serializer (JSON, CBOR etc.) instead:

Expand boilerplate code snippet
// cmd/remote-control/main.go

import (
	"encoding/json"

	"github.com/pojntfx/panrpc/go/pkg/rpc"
	"nhooyr.io/websocket"
)

func main() {
  // ...

 // Connect to WebSocket server
	c, _, err := websocket.Dial(ctx, "ws://127.0.0.1:1337", nil)
	if err != nil {
		panic(err)
	}

	conn := websocket.NetConn(ctx, c, websocket.MessageText)
	defer conn.Close()

	log.Println("Connected to localhost:1337")

	// Set up the streaming JSON encoder and decoder
	encoder := json.NewEncoder(conn)
	decoder := json.NewDecoder(conn)

	if err := registry.LinkStream(
    ctx,

		func(v rpc.Message[json.RawMessage]) error {
			return encoder.Encode(v)
		},
		func(v *rpc.Message[json.RawMessage]) error {
			return decoder.Decode(v)
		},

		func(v any) (json.RawMessage, error) {
			b, err := json.Marshal(v)
			if err != nil {
				return nil, err
			}

			return json.RawMessage(b), nil
		},
		func(data json.RawMessage, v any) error {
			return json.Unmarshal([]byte(data), v)
		},
	); err != nil {
		panic(err)
	}
}

Cheers! You've created your first panrpc client. You can start it from your terminal like so:

$ go run ./cmd/remote-control/main.go

You should now see the following in your terminal, which means that the client has connected to the panrpc server at localhost:1337:

Connected to localhost:1337
1 coffee machines connected

Similarly so, the coffee machine server should output the following:

1 remote controls connected

4. Calling the Server's RPCs from the Client

The coffee machine and the client are now connected to each other, but we haven't added the ability to call the BrewCoffee RPC from the remote control just yet. To fix this, we'll create a simple TUI interface that will print a list of available coffee variants and sizes to the terminal, waits for the user to make their choice by entering a number, and then calls the BrewCoffee RPC with the correct arguments. After the coffee has been brewed, we'll print the new water level to the terminal.

Expand section

To achieve this, we can call this RPC transparently from the remote control by accessing the connected coffee machine(s) with registry.ForRemotes, and we can handle errors by checking with if err := ..., err != nil { ... } just like if we were making a local function call:

// cmd/remote-control/main.go

import (
	"bufio"
	"log"
	"os"
)

func main() {
  // ...

  go func() {
		log.Println(`Enter one of the following numbers followed by <ENTER> to brew a coffee:

- 1: Brew small Cafè Latte
- 2: Brew large Cafè Latte

- 3: Brew small Americano
- 4: Brew large Americano`)

		stdin := bufio.NewReader(os.Stdin)

		for {
			line, err := stdin.ReadString('\n')
			if err != nil {
				panic(err)
			}

			if err := registry.ForRemotes(func(remoteID string, remote coffeeMachine) error {
				switch line {
				case "1\n":
					fallthrough
				case "2\n":
					res, err := remote.BrewCoffee(
						ctx,
						"latte",
						func() int {
							if line == "1" {
								return 100
							} else {
								return 200
							}
						}(),
					)
					if err != nil {
						log.Println("Couldn't brew Cafè Latte:", err)

						return nil
					}

					log.Println("Remaining water:", res, "ml")

				case "3\n":
					fallthrough
				case "4\n":
					res, err := remote.BrewCoffee(
						ctx,
						"americano",
						func() int {
							if line == "1" {
								return 100
							} else {
								return 200
							}
						}(),
					)
					if err != nil {
						log.Println("Couldn't brew Americano:", err)

						return nil
					}

					log.Println("Remaining water:", res, "ml")

				default:
					log.Printf("Unknown letter %v, ignoring input", line)

					return nil
				}

				return nil
			}); err != nil {
				panic(err)
			}
		}
	}()

  // ...
}

Note that by cancelling the context.Context that we pass in as the first argument to every RPC call, you can cancel an RPC call before it has returned, which is useful for implementing things like timeouts. If you don't cancel this context.Context like we do in this example, the RPC call will simply block until it returns.

Now we can restart the remote control like so:

$ go run ./cmd/remote-control/main.go

After which you should see the following output:

Enter one of the following numbers followed by <ENTER> to brew a coffee:

- 1: Brew small Cafè Latte
- 2: Brew large Cafè Latte

- 3: Brew small Americano
- 4: Brew large Americano
1 coffee machines connected
Connected to localhost:1337

It is now possible to brew a coffee by pressing a number and ENTER. Once the RPC has been called, the coffee machine should print something like the following:

Brewing coffee variant latte in size 100 ml

And after the coffee has been brewed, the remote control should return the remaining water level like so:

Remaining water: 900 ml

Enjoy your (virtual) coffee! You've successfully called an RPC provided by a server from the client. Feel free to try out the other supported variants and sizes until there is no more water remaining.

5. Calling the Client's RPCs from the Server

So far, we've enabled a remote control/client to call the BrewCoffee RPC on the coffee machine/server. This however means that if multiple remote controls are connected to one coffee machine, only the remote control that called the RPC is aware of coffee being brewed. In order to notify the other remote controls that coffee is being brewed, we will use panrpc to call a new RPC on the remote control/client from the coffee machine/server each time we brew coffee.

Expand section

To get started, we can once again create a basic struct on the client with a method SetCoffeeMachineBrewing, which will print the state of the coffee machine to the remote control's terminal:

// cmd/remote-control/main.go

type remoteControl struct{}

func (s *remoteControl) SetCoffeeMachineBrewing(ctx context.Context, brewing bool) error {
	if brewing {
		log.Println("Coffee machine is now brewing")
	} else {
		log.Println("Coffee machine has stopped brewing")
	}

	return nil
}

To start turning this new SetCoffeeMachineBrewing method into an RPC that server can call, create an instance of the struct and pass it to the client's registry like so:

// cmd/remote-control/main.go

func main() {
  // ...

  registry := rpc.NewRegistry[coffeeMachine, json.RawMessage](
		&remoteControl{},

		&rpc.RegistryHooks{
			OnClientConnect: func(remoteID string) {
				log.Printf("%v coffee machines connected", clients.Add(1))
			},
			OnClientDisconnect: func(remoteID string) {
				log.Printf("%v coffee machines connected", clients.Add(-1))
			},
		},
	)

  // ...
}

The remote control/client now exposes the SetCoffeeMachineBrewing RPC, and we can start enabling the coffee machine/server to call it by defining a basic struct with a method that mirrors the RPC, just like we did before on the remote control for BrewCoffee:

// cmd/coffee-machine/main.go

type remoteControl struct {
	SetCoffeeMachineBrewing func(ctx context.Context, brewing bool) error
}

In order to make the SetCoffeeMachineBrewing placeholder method do RPC calls, create an instance of the struct and pass it to the server's registry like so:

// cmd/coffee-machine/main.go

func main() {
  // ...

	registry := rpc.NewRegistry[remoteControl, json.RawMessage](
		service,

		&rpc.RegistryHooks{
			OnClientConnect: func(remoteID string) {
				log.Printf("%v remote controls connected", clients.Add(1))
			},
			OnClientDisconnect: func(remoteID string) {
				log.Printf("%v remote controls connected", clients.Add(-1))
			},
		},
	)

  // ...
}

The coffee machine/server and the remote control/client now both know of the new SetCoffeeMachineBrewing RPC, but the server doesn't call it yet. To fix this, we can call this RPC transparently from the coffee machine by accessing the connected remote control(s) with registry.ForRemotes just like we did before in the remote control, and we can handle errors by checking with if err := ..., err != nil { ... } just like if we were making a local function call. We'll also use the first argument to the RPC, ctx, in conjunction with rpc.GetRemoteID to get the ID of the remote control/client that is calling BrewCoffee, so that we don't call SetCoffeeMachineBrewing on the remote control/client that is calling BrewCoffee itself:

// cmd/remote-control/main.go

type coffeeMachine struct {
	supportedVariants []string
	waterLevel        int

	ForRemotes func(
		cb func(remoteID string, remote remoteControl) error,
	) error
}

func (s *coffeeMachine) BrewCoffee(
	ctx context.Context,
	variant string,
	size int,
) (int, error) {
  // Get the ID of the remote control that's calling `BrewCoffee`
	targetID := rpc.GetRemoteID(ctx)

  // Notify connected remote controls that coffee is no longer brewing
	defer s.ForRemotes(func(remoteID string, remote remoteControl) error {
    // Don't call `SetCoffeeMachineBrewing` if it's the remote control that's calling `BrewCoffee`
		if remoteID == targetID {
			return nil
		}

		return remote.SetCoffeeMachineBrewing(ctx, false)
	})

  // Notify connected remote controls that coffee is brewing
	if err := s.ForRemotes(func(remoteID string, remote remoteControl) error {
    // Don't call `SetCoffeeMachineBrewing` if it's the remote control that's calling `BrewCoffee`
		if remoteID == targetID {
			return nil
		}

		return remote.SetCoffeeMachineBrewing(ctx, true)
	}); err != nil {
		return 0, err
	}

	if !slices.Contains(s.supportedVariants, variant) {
		return 0, errors.New("unsupported variant")
	}

	if s.waterLevel-size < 0 {
		return 0, errors.New("not enough water")
	}

	log.Println("Brewing coffee variant", variant, "in size", size, "ml")

	time.Sleep(time.Second * 5)

	s.waterLevel -= size

	return s.waterLevel, nil
}

Note that we've added the forRemotes field to the coffee machine/server; we can get the implementation for it from the registry like so:

// cmd/coffee-machine/main.go

func main{
  service := // ...

  registry := // ...
  service.forRemotes = registry.ForRemotes;
}

Now that we've added support for this RPC to the coffee machine/server, we can restart it like so:

$ go run ./cmd/coffee-machine/main.go

To test if it works, connect two remote controls/clients to it like so:

$ go run ./cmd/remote-control/main.go
# In another terminal
$ go run ./cmd/remote-control/main.go

You can now request the coffee machine to brew a coffee on either of the remote controls by pressing a number and ENTER. Once the RPC has been called, the coffee machine should print something like the following again:

Brewing coffee variant latte in size 100 ml

And after the coffee has been brewed, the remote control that you've chosen to brew the coffee with should once again return the remaining water level like so:

Remaining water: 900 ml

The other connected remote controls will be notified that the coffee machine is brewing, and then once it has finished brewing:

Coffee machine is now brewing
Coffee machine has stopped brewing

Enjoy your distributed coffee machine! You've successfully called an RPC provided by a client from the server to implement multicast notifications, something that usually is quite complex to do with RPC systems.

6. Passing Closures to RPCs

So far, when the remote control/client calls the BrewCoffee RPC, there is no way of knowing the incremental progress of the brew other than waiting for BrewCoffee to return the new water level. In order to know of the progress of the coffee machine as it is brewing, we can make use of the closure/callback support in panrpc, which allows us to pass a function to an RPC call, just like you could do locally.

Expand section

First, we'll add a onProgress callback to the coffee machine's BrewCoffee implementation, which we then call incrementally during the brewing process:

// cmd/coffee-machine/main.go

func (s *coffeeMachine) BrewCoffee(
	ctx context.Context,
	variant string,
	size int,
	onProgress func(ctx context.Context, percentage int) error, // This is new
) (int, error) {
	// ...

	// Report 0% brewing process
	if err := onProgress(ctx, 0); err != nil {
		return 0, err
	}

	// Report 25% brewing process
	time.Sleep(500 * time.Millisecond)
	if err := onProgress(ctx, 25); err != nil {
		return 0, err
	}

	// Report 50% brewing process
	time.Sleep(500 * time.Millisecond)
	if err := onProgress(ctx, 50); err != nil {
		return 0, err
	}

	// Report 75% brewing process
	time.Sleep(500 * time.Millisecond)
	if err := onProgress(ctx, 75); err != nil {
		return 0, err
	}

	// Report 100% brewing process
	time.Sleep(500 * time.Millisecond)
	if err := onProgress(ctx, 100); err != nil {
		return 0, err
	}

	// ..

	return s.waterLevel, nil
}

In the remote control, we'll also extend the struct with the BrewCoffee placeholder method with this new RPC argument:

// cmd/remote-control/main.go

type coffeeMachine struct {
	BrewCoffee func(
		ctx context.Context,
		variant string,
		size int,
		onProgress func(ctx context.Context, percentage int) error, // This is new
	) (int, error)
}

And finally, where we call the BrewCoffee RPC in the remote control/client, we can pass in the implementation of this closure:

// cmd/remote-control/main.go

go func() {
	// ...
	for {
		// ...
		if err := registry.ForRemotes(func(remoteID string, remote coffeeMachine) error {
			switch line {
			case "1\n":
				fallthrough
			case "2\n":
				res, err := remote.BrewCoffee(
					ctx,
					"latte",
					func() int {
						if line == "1" {
							return 100
						} else {
							return 200
						}
					}(),
					func(ctx context.Context, percentage int) error {
						log.Printf(`Brewing Cafè Latte ... %v%% done`, percentage) // This is new

						return nil
					},
				)

				// ...

			case "3\n":
				fallthrough
			case "4\n":
				res, err := remote.BrewCoffee(
					ctx,
					"americano",
					func() int {
						if line == "1" {
							return 100
						} else {
							return 200
						}
					}(),
					func(ctx context.Context, percentage int) error {
						log.Printf(`Brewing Americano ... %v%% done`, percentage) // This is new

						return nil
					},
				)

				// ...

			return nil
		}); err != nil {
			panic(err)
		}
	}
}()

Now we can restart the coffee machine/server again like so:

$ go run ./cmd/coffee-machine/main.go

And connect the remote control/client to it again like so:

$ go run ./cmd/remote-control/main.go

You can now request the coffee machine to brew a coffee by pressing a number and ENTER. Once the RPC has been called, the coffee machine should print something like the following again:

Brewing coffee variant latte in size 100 ml

And the remote control will print the progress as reported by the coffee machine to the terminal, before once again returning the remaining water level like so:

Brewing Cafè Latte ... 0% done
Brewing Cafè Latte ... 25% done
Brewing Cafè Latte ... 50% done
Brewing Cafè Latte ... 75% done
Brewing Cafè Latte ... 100% done
Remaining water: 900 ml

Enjoy your live coffee brewing progress! You've successfully implemented incremental coffee brewing progress reports by using panrpc's closure support, something that is usually quite tricky to do with RPC frameworks.

7. Nesting RPCs

So far, we've added RPCs directly to our coffee machine/server and remote control/client. While this approach is simple, it makes future extensions difficult. If we want to add more features, we would need to modify the coffee machine/server and remote control/client directly by adding new RPC methods, which can be hard to do in a type-safe way. Additionally, having only one level of RPCs makes large APIs hard to understand and organize as the number of RPCs increases.

In order to work around this, panrpc supports nesting RPCs in both clients and servers. This allows you to simplify top-level RPC calls; for example, instead of a single RPC like GetConnectedChatUsers(), you can use categorized, nested calls such as Chat.Users.GetConnected().

Expand section

To define a nested RPC, simply add another struct as a public property to your existing coffee machine/server or remote control/client. In this new stuct, you can define RPCs as methods, just like with top-level RPCs. To call them, follow the same concept: Define placeholder methods in the new struct and add it as a public property to your coffee machine/server or remote control/client.

In this example, we'll add a tea brewer extension to our coffee machine/server and remote control/client. This extension will allow us to list available tea variants by calling the Extension.GetVariants RPC. For brevity, we won't implement all the tea brewing functions. To add the tea brewer extension to the coffee machine/server, first create a new TeaBrewer struct, similar to the CoffeeMachine struct. Then, add the GetVariants method to this class and instantiate it with the supported variants:

// cmd/coffee-machine/main.go

type teaBrewer struct {
	supportedVariants []string
}

func (s *teaBrewer) GetVariants(ctx context.Context) ([]string, error) {
	return s.supportedVariants, nil
}

To add these nested RPCs to the main CoffeeMachine struct, we'll include them as a public property in the struct. To keep the coffee machine flexible and avoid depending on the TeaBrewer extension, we'll name the property Extension and make it generic in CoffeeMachine. This way, we can easily replace the tea brewer with another extension in the future, such as a full-featured tea brewer:

// cmd/coffee-machine/main.go

type coffeeMachine[E any] struct {
	Extension E

  // ...
}

Finally, for the coffee machine/server, create an instance of our extension and pass it to the CoffeeMachine when initializing it. This is also where you provide the list of supported tea variants:

// cmd/coffee-machine/main.go

// ...
service := &coffeeMachine[*teaBrewer]{
		Extension: &teaBrewer{
			supportedVariants: []string{"darjeeling", "chai", "earlgrey"},
		},

		supportedVariants: []string{"latte", "americano"},
		waterLevel:        1000,
	}
// ...

For the remote control/client, it's a very similar process. First, we define the new TeaBrewer struct with the GetVariants placeholder method:

// cmd/remote-control/main.go

type teaBrewer struct {
	GetVariants func(ctx context.Context) ([]string, error)
}

Then we'll add the nested RPCs to the main CoffeeMachine struct as a public instance property, and we'll use generics again to keep everything extensible:

// cmd/remote-control/main.go

type coffeeMachine[E any] struct {
	Extension E

	// ...
}

After this, we need to create an instance of our extension and pass it to CoffeeMachine when we create it:

// cmd/remote-control/main.go

registry := rpc.NewRegistry[coffeeMachine[teaBrewer], json.RawMessage](
		&remoteControl{},

		// ...
	)

And finally, we add another switch case to the remote control/client so that we can call Extension.GetVariants:

// cmd/remote-control/main.go

	go func() {
		log.Println(`Enter one of the following numbers followed by <ENTER> to brew a coffee:

- 1: Brew small Cafè Latte
- 2: Brew large Cafè Latte

- 3: Brew small Americano
- 4: Brew large Americano

Or enter 5 to list available tea variants.`)

  // ...

  if err := registry.ForRemotes(func(remoteID string, remote coffeeMachine[teaBrewer]) error {
		switch line {
      // ..
      case "5\n":
				res, err := remote.Extension.GetVariants(ctx)
				if err != nil {
					log.Println("Couldn't list available tea variants:", err)

					return nil
				}

				log.Println("Available tea variants:", res)

      default:
      // ..
		}
	}
}()

Now we can restart the coffee machine/server again like so:

$ go run ./cmd/coffee-machine/main.go

And connect the remote control/client to it again like so:

$ go run ./cmd/remote-control/main.go

You can now request the coffee machine to list the available tea variants by pressing 5 and ENTER. Once the RPC has been called, the remote control should print something like the following:

Available tea variants: [ "darjeeling", "chai", "earlgrey" ]

🚀 That's it! You've successfully built a virtual coffee machine with support for brewing coffee, notifications when coffee is being brewed, and incremental coffee brewing progress reports. You've also made it easily extensible by using nested RPCs. We can't wait to see what you're going to build next with panrpc! Be sure to take a look at the reference and examples for more information, or check out the complete sources for the coffee machine server and coffee machine client/remote control for a recap.

typescript TypeScript

Just looking for sample code? Check out the sources for the example coffee machine server and coffee machine client/remote control.

1. Choosing a Transport and Serializer

Expand section

Start by creating a new npm module for the tutorial and installing @pojntfx/panrpc:

$ mkdir -p panrpc-tutorial-typescript
$ cd panrpc-tutorial-typescript
$ npm init -y
$ npm install @pojntfx/panrpc

The TypeScript version of panrpc supports many transports. While common ones are TCP, WebSockets, UNIX sockets or WebRTC, anything that directly implements or can be adapted to a WHATWG stream can be used with the panrpc linkStream API. If you want to use a message broker like Valkey/Redis or NATS as the transport, or need more control over the wire protocol, you can use the linkMessage API instead. For this tutorial, we'll be using WebSockets as the transport through the ws library, which you can install like so:

$ npm install ws

In addition to supporting many transports, the TypeScript version of panrpc also supports different serializers. Common ones are JSON and CBOR, but similarly to transports anything that implements or can be adapted to a WHATWG stream can be used. For this tutorial, we'll be using JSON as the serializer through the @streamparser/json-whatwg library, which you can install like so:

$ npm install @streamparser/json-whatwg

2. Creating a Server

In this tutorial we'll be creating a simple coffee machine server that simulates brewing coffee, and can be controlled by using a remote control (the coffee machine client).

Expand section

To start with implementing the coffee machine server, create a new file coffee-machine.ts and define a basic class with a BrewCoffee method. This method simulates brewing coffee by validating the coffee variant, checking if there is enough water available to brew the coffee, sleeping for five seconds, and returning the new water level to the remote control:

// coffee-machine.ts

import { ILocalContext } from "@pojntfx/panrpc";

class CoffeeMachine {
  #waterLevel: number;

  constructor(private supportedVariants: string[], waterLevel: number) {
    this.#waterLevel = waterLevel;

    this.BrewCoffee = this.BrewCoffee.bind(this);
  }

  async BrewCoffee(
    ctx: ILocalContext,
    variant: string,
    size: number
  ): Promise<number> {
    if (!this.supportedVariants.includes(variant)) {
      throw new Error("unsupported variant");
    }

    if (this.#waterLevel - size < 0) {
      throw new Error("not enough water");
    }

    console.log("Brewing coffee variant", variant, "in size", size, "ml");

    await new Promise((r) => {
      setTimeout(r, 5000);
    });

    this.#waterLevel -= size;

    return this.#waterLevel;
  }
}

The following limitations on which methods can be exposed as RPCs exist:

  • Methods must have ILocalContext as their first argument
  • Methods can not have variadic arguments
  • Methods must be public (private methods - those that start with #, see private properties - won't be callable as RPCs, but stay callable as regular methods)

To start turning the BrewCoffee method into an RPC, create an instance of the class and pass it to a panrpc Registry like so:

// coffee-machine.ts

import { Registry } from "@pojntfx/panrpc";

const service = new CoffeeMachine(["latte", "americano"], 1000);

let clients = 0;

const registry = new Registry(
  service,
  new (class {})(),

  {
    onClientConnect: () => {
      clients++;

      console.log(clients, "remote controls connected");
    },
    onClientDisconnect: () => {
      clients--;

      console.log(clients, "remote controls connected");
    },
  }
);

Now that we have a registry that provides our coffee machine's RPCs, we can link it to our transport (WebSockets) and serializer of choice (JSON). This requires a bit of boilerplate since the ws library doesn't provide WHATWG streams directly yet, so feel free to copy-and-paste this, or take a look at the examples to check out how you can set up a different transport (TCP, WebSockets, UNIX sockets etc.) and serializer (JSON, CBOR etc.) instead:

Expand boilerplate code snippet
// coffee-machine.ts

import { JSONParser } from "@streamparser/json-whatwg";
import { WebSocketServer } from "ws";

// Create WebSocket server
const server = new WebSocketServer({
  host: "127.0.0.1",
  port: 1337,
});

server.on("connection", (socket) => {
  socket.addEventListener("error", (e) => {
    console.error("Remote control disconnected with error:", e);
  });

  const linkSignal = new AbortController();

  // Set up streaming JSON encoder
  const encoder = new WritableStream({
    write(chunk) {
      socket.send(JSON.stringify(chunk));
    },
  });

  // Set up streaming JSON decoder
  const parser = new JSONParser({
    paths: ["$"],
    separator: "",
  });
  const parserWriter = parser.writable.getWriter();
  const parserReader = parser.readable.getReader();
  const decoder = new ReadableStream({
    start(controller) {
      parserReader
        .read()
        .then(async function process({ done, value }) {
          if (done) {
            controller.close();

            return;
          }

          controller.enqueue(value?.value);

          parserReader
            .read()
            .then(process)
            .catch((e) => controller.error(e));
        })
        .catch((e) => controller.error(e));
    },
  });
  socket.addEventListener("message", (m) =>
    parserWriter.write(m.data as string)
  );
  socket.addEventListener("close", () => {
    parserReader.cancel();
    parserWriter.abort();
    linkSignal.abort();
  });

  registry.linkStream(
    linkSignal.signal,

    encoder,
    decoder,

    (v) => v,
    (v) => v
  );
});

console.log("Listening on localhost:1337");

Congratulations! You've created your first panrpc server. You can start it from your terminal like so:

$ npx tsx coffee-machine.ts

You should now see the following in your terminal, which means that the server is available on localhost:1337:

Listening on localhost:1337

3. Creating a Client

In order to interact with the coffee machine server, we'll now create the remote control (the coffee machine client), which will call the BrewCoffee RPC.

Expand section

To start with implementing the remote control, create a new file remote-control.ts and define a basic class with a placeholder method that mirrors the BrewCoffee RPC:

// remote-control.ts

import { IRemoteContext } from "@pojntfx/panrpc";

class CoffeeMachine {
  async BrewCoffee(
    ctx: IRemoteContext,
    variant: string,
    size: number
  ): Promise<number> {
    return 0;
  }
}

Placeholder methods must have IRemoteContext instead of ILocalContext as their first argument.

In order to make the BrewCoffee placeholder method do RPC calls, create an instance of the class and pass it to a panrpc Registry like so:

// remote-control.ts

import { Registry } from "@pojntfx/panrpc";

let clients = 0;

const registry = new Registry(
  new (class {})(),
  new CoffeeMachine(),

  {
    onClientConnect: () => {
      clients++;

      console.log(clients, "coffee machines connected");
    },
    onClientDisconnect: () => {
      clients--;

      console.log(clients, "coffee machines connected");
    },
  }
);

Now that we have a registry that turns the remote control's placeholder methods into RPC calls, we can link it to our transport (WebSockets) and serializer of choice (JSON). Once again, this requires a bit of boilerplate since the ws library doesn't provide WHATWG streams directly yet, so feel free to copy-and-paste this, or take a look at the examples to check out how you can set up a different transport (TCP, WebSockets, UNIX sockets etc.) and serializer (JSON, CBOR etc.) instead:

Expand boilerplate code snippet
// remote-control.ts

import { JSONParser } from "@streamparser/json-whatwg";
import { WebSocket } from "ws";

// Connect to WebSocket server
const socket = new WebSocket("ws://127.0.0.1:1337");

socket.addEventListener("error", (e) => {
  console.error("Disconnected with error:", e);

  exit(1);
});
socket.addEventListener("close", () => exit(0));

await new Promise<void>((res, rej) => {
  socket.addEventListener("open", () => res());
  socket.addEventListener("error", rej);
});

const linkSignal = new AbortController();

// Set up streaming JSON encoder
const encoder = new WritableStream({
  write(chunk) {
    socket.send(JSON.stringify(chunk));
  },
});

// Set up streaming JSON decoder
const parser = new JSONParser({
  paths: ["$"],
  separator: "",
});
const parserWriter = parser.writable.getWriter();
const parserReader = parser.readable.getReader();
const decoder = new ReadableStream({
  start(controller) {
    parserReader
      .read()
      .then(async function process({ done, value }) {
        if (done) {
          controller.close();

          return;
        }

        controller.enqueue(value?.value);

        parserReader
          .read()
          .then(process)
          .catch((e) => controller.error(e));
      })
      .catch((e) => controller.error(e));
  },
});
socket.addEventListener("message", (m) => parserWriter.write(m.data as string));
socket.addEventListener("close", () => {
  parserReader.cancel();
  parserWriter.abort();
  linkSignal.abort();
});

registry.linkStream(
  linkSignal.signal,

  encoder,
  decoder,

  (v) => v,
  (v) => v
);

console.log("Connected to localhost:1337");

Cheers! You've created your first panrpc client. You can start it from your terminal like so:

$ npx tsx remote-control.ts

You should now see the following in your terminal, which means that the client has connected to the panrpc server at localhost:1337:

Connected to localhost:1337
1 coffee machines connected

Similarly so, the coffee machine server should output the following:

1 remote controls connected

4. Calling the Server's RPCs from the Client

The coffee machine and the client are now connected to each other, but we haven't added the ability to call the BrewCoffee RPC from the remote control just yet. To fix this, we'll create a simple TUI interface that will print a list of available coffee variants and sizes to the terminal, waits for the user to make their choice by entering a number, and then calls the BrewCoffee RPC with the correct arguments. After the coffee has been brewed, we'll print the new water level to the terminal.

Expand section

To achieve this, we can call this RPC transparently from the remote control by accessing the connected coffee machine(s) with registry.forRemotes, and we can handle errors with try catch just like if we were making a local function call:

// remote-control.ts

import { createInterface } from "readline/promises";

(async () => {
  console.log(`Enter one of the following numbers followed by <ENTER> to brew a coffee:

- 1: Brew small Cafè Latte
- 2: Brew large Cafè Latte

- 3: Brew small Americano
- 4: Brew large Americano`);

  const rl = createInterface({ input: stdin, output: stdout });

  while (true) {
    const line = await rl.question("");

    await registry.forRemotes(async (remoteID, remote) => {
      switch (line) {
        case "1":
        case "2":
          try {
            const res = await remote.BrewCoffee(
              undefined,
              "latte",
              line === "1" ? 100 : 200
            );

            console.log("Remaining water:", res, "ml");
          } catch (e) {
            console.error(`Couldn't brew Cafè Latte: ${e}`);
          }

          break;

        case "3":
        case "4":
          try {
            const res = await remote.BrewCoffee(
              undefined,
              "americano",
              line === "3" ? 100 : 200
            );

            console.log("Remaining water:", res, "ml");
          } catch (e) {
            console.error(`Couldn't brew Americano: ${e}`);
          }

          break;

        default:
          console.log(`Unknown letter ${line}, ignoring input`);
      }
    });
  }
})();

Note that by aborting the AbortSignal that we can pass in as the first argument to every RPC call, you can abort an RPC call before it has returned, which is useful for implementing things like timeouts. If you don't abort this AbortSignal, or pass in undefined like we do in this example, the RPC call will simply block until it returns.

Now we can restart the remote control like so:

$ npx tsx remote-control.ts

After which you should see the following output:

Enter one of the following numbers followed by <ENTER> to brew a coffee:

- 1: Brew small Cafè Latte
- 2: Brew large Cafè Latte

- 3: Brew small Americano
- 4: Brew large Americano
1 coffee machines connected
Connected to localhost:1337

It is now possible to brew a coffee by pressing a number and ENTER. Once the RPC has been called, the coffee machine should print something like the following:

Brewing coffee variant latte in size 100 ml

And after the coffee has been brewed, the remote control should return the remaining water level like so:

Remaining water: 900 ml

Enjoy your (virtual) coffee! You've successfully called an RPC provided by a server from the client. Feel free to try out the other supported variants and sizes until there is no more water remaining.

5. Calling the Client's RPCs from the Server

So far, we've enabled a remote control/client to call the BrewCoffee RPC on the coffee machine/server. This however means that if multiple remote controls are connected to one coffee machine, only the remote control that called the RPC is aware of coffee being brewed. In order to notify the other remote controls that coffee is being brewed, we will use panrpc to call a new RPC on the remote control/client from the coffee machine/server each time we brew coffee.

Expand section

To get started, we can once again create a basic class on the client with a method SetCoffeeMachineBrewing, which will print the state of the coffee machine to the remote control's terminal:

// remote-control.ts

class RemoteControl {
  async SetCoffeeMachineBrewing(ctx: ILocalContext, brewing: boolean) {
    if (brewing) {
      console.log("Coffee machine is now brewing");
    } else {
      console.log("Coffee machine has stopped brewing");
    }
  }
}

To start turning this new SetCoffeeMachineBrewing method into an RPC that server can call, create an instance of the class and pass it to the client's registry like so:

// remote-control.ts

const registry = new Registry(
  new RemoteControl(), // This line is new
  new CoffeeMachine(),

  {
    onClientConnect: () => {
      clients++;

      console.log(clients, "coffee machines connected");
    },
    onClientDisconnect: () => {
      clients--;

      console.log(clients, "coffee machines connected");
    },
  }
);

The remote control/client now exposes the SetCoffeeMachineBrewing RPC, and we can start enabling the coffee machine/server to call it by defining a basic class with a method that mirrors the RPC, just like we did before on the remote control for BrewCoffee:

// coffee-machine.ts

class RemoteControl {
  async SetCoffeeMachineBrewing(ctx: IRemoteContext, brewing: boolean) {}
}

In order to make the SetCoffeeMachineBrewing placeholder method do RPC calls, create an instance of the class and pass it to the server's registry like so:

// coffee-machine.ts

const registry = new Registry(
  service,
  new RemoteControl(), // This line is new

  {
    onClientConnect: () => {
      clients++;

      console.log(clients, "remote controls connected");
    },
    onClientDisconnect: () => {
      clients--;

      console.log(clients, "remote controls connected");
    },
  }
);

The coffee machine/server and the remote control/client now both know of the new SetCoffeeMachineBrewing RPC, but the server doesn't call it yet. To fix this, we can call this RPC transparently from the coffee machine by accessing the connected remote control(s) with registry.forRemotes just like we did before in the remote control, and we can handle errors with try catch just like if we were making a local function call. We'll also use the first argument to the RPC, ILocalContext, to get the ID of the remote control/client that is calling BrewCoffee, so that we don't call SetCoffeeMachineBrewing on the remote control/client that is calling BrewCoffee itself:

// coffee-machine.ts

class CoffeeMachine {
  public forRemotes?: (
    cb: (remoteID: string, remote: RemoteControl) => Promise<void>
  ) => Promise<void>;

  // ...

  async BrewCoffee(
    ctx: ILocalContext,
    variant: string,
    size: number
  ): Promise<number> {
    // Get the ID of the remote control that's calling `BrewCoffee`
    const { remoteID: targetID } = ctx;

    try {
      // Notify connected remote controls that coffee is brewing
      await this.forRemotes?.(async (remoteID, remote) => {
        // Don't call `SetCoffeeMachineBrewing` if it's the remote control that's calling `BrewCoffee`
        if (remoteID === targetID) {
          return;
        }

        await remote.SetCoffeeMachineBrewing(undefined, true);
      });

      if (!this.supportedVariants.includes(variant)) {
        throw new Error("unsupported variant");
      }

      if (this.#waterLevel - size < 0) {
        throw new Error("not enough water");
      }

      console.log("Brewing coffee variant", variant, "in size", size, "ml");

      await new Promise((r) => {
        setTimeout(r, 5000);
      });
    } finally {
      // Notify connected remote controls that coffee is no longer brewing
      await this.forRemotes?.(async (remoteID, remote) => {
        // Don't call `SetCoffeeMachineBrewing` if it's the remote control that's calling `BrewCoffee`
        if (remoteID === targetID) {
          return;
        }

        await remote.SetCoffeeMachineBrewing(undefined, false);
      });
    }

    this.#waterLevel -= size;

    return this.#waterLevel;
  }
}

Note that we've added the forRemotes field to the coffee machine/server; we can get the implementation for it from the registry like so:

// coffee-machine.ts

const service = // ...

const registry = // ...

service.forRemotes = registry.forRemotes;

Now that we've added support for this RPC to the coffee machine/server, we can restart it like so:

$ npx tsx coffee-machine.ts

To test if it works, connect two remote controls/clients to it like so:

$ npx tsx remote-control.ts
# In another terminal
$ npx tsx remote-control.ts

You can now request the coffee machine to brew a coffee on either of the remote controls by pressing a number and ENTER. Once the RPC has been called, the coffee machine should print something like the following again:

Brewing coffee variant latte in size 100 ml

And after the coffee has been brewed, the remote control that you've chosen to brew the coffee with should once again return the remaining water level like so:

Remaining water: 900 ml

The other connected remote controls will be notified that the coffee machine is brewing, and then once it has finished brewing:

Coffee machine is now brewing
Coffee machine has stopped brewing

Enjoy your distributed coffee machine! You've successfully called an RPC provided by a client from the server to implement multicast notifications, something that usually is quite complex to do with RPC systems.

6. Passing Closures to RPCs

So far, when the remote control/client calls the BrewCoffee RPC, there is no way of knowing the incremental progress of the brew other than waiting for BrewCoffee to return the new water level. In order to know of the progress of the coffee machine as it is brewing, we can make use of the closure/callback support in panrpc, which allows us to pass a function to an RPC call, just like you could do locally.

Expand section

First, we'll add a onProgress callback to the coffee machine's BrewCoffee implementation and decorate it with panrpc's @remoteClosure decorator, which we then call incrementally during the brewing process:

// coffee-machine.ts

import { remoteClosure } from "@pojntfx/panrpc";

class CoffeeMachine {
  // ...

  async BrewCoffee(
    ctx: ILocalContext,
    variant: string,
    size: number,
    @remoteClosure
    onProgress: (ctx: IRemoteContext, percentage: number) => Promise<void> // This is new
  ): Promise<number> {
    // ...

    try {
      // ...

      // Report 0% brewing process
      await onProgress(undefined, 0);

      // Report 25% brewing process
      await new Promise((r) => {
        setTimeout(r, 500);
      });
      await onProgress(undefined, 25);

      // Report 50% brewing process
      await new Promise((r) => {
        setTimeout(r, 500);
      });
      await onProgress(undefined, 50);

      // Report 75% brewing process
      await new Promise((r) => {
        setTimeout(r, 500);
      });
      await onProgress(undefined, 75);

      // Report 100% brewing process
      await new Promise((r) => {
        setTimeout(r, 500);
      });
      await onProgress(undefined, 100);
    }

    // ..

    return this.#waterLevel;
  }
}

In the remote control, we'll also extend the class with the BrewCoffee placeholder method with this new RPC argument:

// remote-control.ts

class CoffeeMachine {
  async BrewCoffee(
    ctx: IRemoteContext,
    variant: string,
    size: number,
    onProgress: (ctx: ILocalContext, percentage: number) => Promise<void> // This is new
  ): Promise<number> {
    return 0;
  }
}

And finally, where we call the BrewCoffee RPC in the remote control/client, we can pass in the implementation of this closure:

// remote-control.ts

(async () => {
  // ...
  await registry.forRemotes(async (remoteID, remote) => {
    switch (line) {
      case "1":
      case "2":
        // ...
        const res = await remote.BrewCoffee(
          undefined,
          "latte",
          line === "1" ? 100 : 200,
          async (ctx, percentage) =>
            console.log(`Brewing Cafè Latte ... ${percentage}% done`) // This is new
        );

      // ...

      case "3":
      case "4":
        // ...
        const res = await remote.BrewCoffee(
          undefined,
          "americano",
          line === "3" ? 100 : 200,
          async (ctx, percentage) =>
            console.log(`Brewing Americano ... ${percentage}% done`) // This is new
        );

      // ..
    }
  });
})();

Now we can restart the coffee machine/server again like so:

$ npx tsx coffee-machine.ts

And connect the remote control/client to it again like so:

$ npx tsx remote-control.ts

You can now request the coffee machine to brew a coffee by pressing a number and ENTER. Once the RPC has been called, the coffee machine should print something like the following again:

Brewing coffee variant latte in size 100 ml

And the remote control will print the progress as reported by the coffee machine to the terminal, before once again returning the remaining water level like so:

Brewing Cafè Latte ... 0% done
Brewing Cafè Latte ... 25% done
Brewing Cafè Latte ... 50% done
Brewing Cafè Latte ... 75% done
Brewing Cafè Latte ... 100% done
Remaining water: 900 ml

Enjoy your live coffee brewing progress! You've successfully implemented incremental coffee brewing progress reports by using panrpc's closure support, something that is usually quite tricky to do with RPC frameworks.

7. Nesting RPCs

So far, we've added RPCs directly to our coffee machine/server and remote control/client. While this approach is simple, it makes future extensions difficult. If we want to add more features, we would need to modify the coffee machine/server and remote control/client directly by adding new RPC methods, which can be hard to do in a type-safe way. Additionally, having only one level of RPCs makes large APIs hard to understand and organize as the number of RPCs increases.

In order to work around this, panrpc supports nesting RPCs in both clients and servers. This allows you to simplify top-level RPC calls; for example, instead of a single RPC like GetConnectedChatUsers(), you can use categorized, nested calls such as Chat.Users.GetConnected().

Expand section

To define a nested RPC, simply add another class as a public instance property to your existing coffee machine/server or remote control/client. In this new class, you can define RPCs as methods, just like with top-level RPCs. To call them, follow the same concept: Define placeholder methods in the new class and add it as a public instance property to your coffee machine/server or remote control/client.

In this example, we'll add a tea brewer extension to our coffee machine/server and remote control/client. This extension will allow us to list available tea variants by calling the Extension.GetVariants RPC. For brevity, we won't implement all the tea brewing functions. To add the tea brewer extension to the coffee machine/server, first create a new TeaBrewer class, similar to the CoffeeMachine class. Then, add the GetVariants method to this class and instantiate it with the supported variants:

// coffee-machine.ts

class TeaBrewer {
  #supportedVariants: string[];

  constructor(supportedVariants: string[]) {
    this.#supportedVariants = supportedVariants;

    this.GetVariants = this.GetVariants.bind(this);
  }

  async GetVariants(ctx: ILocalContext): Promise<string[]> {
    return this.#supportedVariants;
  }
}

To add these nested RPCs to the main CoffeeMachine class, we'll include them as a public property in the class’s constructor. To keep the coffee machine flexible and avoid depending on the TeaBrewer extension, we'll name the property Extension and make it generic in CoffeeMachine. This way, we can easily replace the tea brewer with another extension in the future, such as a full-featured tea brewer:

// coffee-machine.ts

class CoffeeMachine<E> {
  // ...
  constructor(
    public Extension: E,
    supportedVariants: string[],
    waterLevel: number
  ) {
    this.#supportedVariants = supportedVariants;
    this.#waterLevel = waterLevel;

    this.BrewCoffee = this.BrewCoffee.bind(this);
  }
  // ...
}

Finally, for the coffee machine/server, create an instance of our extension and pass it to the CoffeeMachine when initializing it. This is also where you provide the list of supported tea variants:

// coffee-machine.ts

// ...
const service = new CoffeeMachine(
  new TeaBrewer(["darjeeling", "chai", "earlgrey"]),
  ["latte", "americano"],
  1000
);
// ...

For the remote control/client, it's a very similar process. First, we define the new TeaBrewer class with the GetVariants placeholder method:

// remote-control.ts

class TeaBrewer {
  async GetVariants(ctx: IRemoteContext): Promise<string[]> {
    return [];
  }
}

Then we'll add the nested RPCs to the main CoffeeMachine class as a public instance property, and we'll use generics again to keep everything extensible:

// remote-control.ts

class CoffeeMachine<E> {
  constructor(public Extension: E) {}

  // ...
}

After this, we need to create an instance of our extension and pass it to CoffeeMachine when we create it:

// remote-control.ts

const registry = new Registry(
  new RemoteControl(),
  new CoffeeMachine(new TeaBrewer())

  // ...
);

And finally, we add another switch case to the remote control/client so that we can call Extension.GetVariants:

// remote-control.ts

(async () => {
  console.log(`Enter one of the following numbers followed by <ENTER> to brew a coffee:

- 1: Brew small Cafè Latte
- 2: Brew large Cafè Latte

- 3: Brew small Americano
- 4: Brew large Americano

Or enter 5 to list available tea variants.`);

  // ...

  await registry.forRemotes(async (remoteID, remote) => {
    switch (line) {
      // ..
      case "5":
        try {
          const res = await remote.Extension.GetVariants(undefined);

          console.log("Available tea variants:", res);
        } catch (e) {
          console.error(`Couldn't list available tea variants: ${e}`);
        }

        break;

      default:
      // ..
    }
  });
})();

Now we can restart the coffee machine/server again like so:

$ npx tsx coffee-machine.ts

And connect the remote control/client to it again like so:

$ npx tsx remote-control.ts

You can now request the coffee machine to list the available tea variants by pressing 5 and ENTER. Once the RPC has been called, the remote control should print something like the following:

Available tea variants: [ "darjeeling", "chai", "earlgrey" ]

🚀 That's it! You've successfully built a virtual coffee machine with support for brewing coffee, notifications when coffee is being brewed, and incremental coffee brewing progress reports. You've also made it easily extensible by using nested RPCs. We can't wait to see what you're going to build next with panrpc! Be sure to take a look at the reference and examples for more information, or check out the complete sources for the coffee machine server and coffee machine client/remote control for a recap.

Reference

Library API

  • Go Reference
  • TypeScript docs

Examples

To make getting started with panrpc easier, take a look at the following examples:

Benchmarks

All benchmarks were conducted on a test machine with the following specifications:

Property Value
Device Model Dell XPS 9320
OS Fedora release 38 (Thirty Eight) x86_64
Kernel 6.3.11-200.fc38.x86_64
CPU 12th Gen Intel i7-1280P (20) @ 4.700GHz
Memory 31687MiB LPDDR5, 6400 MT/s

To reproduce the tests, see the benchmark source code and the visualization source code.

Requests/Second

This is measured by calling RPCs with the different data types as the arguments.

Bar chart of the requests/second benchmark results for JSON and CBOR

Data Type JSON (go) CBOR (go) JSON (typescript) CBOR (typescript)
array 75500 99683 57373 62848
bool 79662 106226 57499 63324
byte 81438 105916 57480 60169
complex128 nan nan 58849 59693
complex64 nan nan 58375 63018
float32 79878 106359 54034 62068
float64 78724 101498 55181 61987
int 93569 119268 52115 59269
int16 76995 104569 56596 62165
int32 80425 106986 53847 63676
int64 81276 101144 58126 64622
int8 85734 113260 54081 60756
rune 84113 109719 53753 61153
slice 77975 101126 56404 62278
string 77252 106265 57876 60453
struct 77699 104968 57876 61498
uint 81361 103698 58455 61729
uint16 80990 106615 57004 62429
uint32 80319 103672 55668 63651
uint64 82412 107139 53627 63818
uint8 82127 106076 59698 59955
uintptr nan nan 53214 64170

Throughput

This is measured by calling an RPC with []byte as the argument.

Bar chart of the throughput benchmark results for JSON and CBOR

Serializer Average Throughput
CBOR (go) 1389 MB/s
JSON (go) 105 MB/s
CBOR (typescript) 24 MB/s
JSON (typescript) 12 MB/s

Protocol

The protocol used by panrpc is simple and independent of transport and serialization layer; in the following examples, we'll use JSON.

A function call to e.g. the Println function from above looks like this:

{
  "request": {
    "call": "b3332cf0-4e50-4684-a909-05772e14595e",
    "function": "Println",
    "args": ["Hello, world!"]
  },
  "response": null
}

The request/response wrapper specifies whether the message is a function call (request) or return (response). call is the ID of the function call, as generated by the client; function is the function name and args is an array of the function's arguments.

A function return looks like this:

{
  "request": null,
  "response": {
    "call": "b3332cf0-4e50-4684-a909-05772e14595e",
    "value": null,
    "err": ""
  }
}

Here, response specifies that the message is a function return. call is the ID of the function call from above, value is the function's return value, and the last element is the error message; nil errors are represented by the empty string.

Keep in mind that panrpc is bidirectional, meaning that both the client and server can send and receive both types of messages to each other.

purl Command Line Arguments

$ purl --help
Like cURL, but for panrpc: Command-line tool for interacting with panrpc servers

Usage of purl:
	purl [flags] <(tcp|tls|unix|unixs|ws|wss|weron)://(host:port/function|path/function|password:key@community/channel[/remote]/function)> <[args...]>

Examples:
	purl tcp://localhost:1337/Increment '[1]'
	purl tls://localhost:443/Increment '[1]'
	purl unix:///tmp/panrpc.sock/Increment '[1]'
	purl unixs:///tmp/panrpc.sock/Increment '[1]'
	purl ws://localhost:1337/Increment '[1]'
	purl wss://localhost:443/Increment '[1]'
	purl weron://examplepass:examplekey@examplecommunity/panrpc.example.webrtc/Increment '[1]'

Flags:
  -listen
    	Whether to connect to remotes by listening or dialing (ignored for weron://)
  -serializer string
    	Serializer to use (json or cbor) (default "json")
  -timeout duration
    	Time to wait for a response to a call (default 10s)
  -tls-cert string
    	TLS certificate (only valid for tls://, unixs:// and wss://)
  -tls-key string
    	TLS key (only valid for tls://, unixs:// and wss://)
  -tls-verify
    	Whether to verify TLS peer certificates (only valid for tls://, unixs:// and wss://) (default true)
  -verbose
    	Whether to enable verbose logging
  -weron-force-relay
    	Force usage of TURN servers (only valid for weron://)
  -weron-ice string
    	Comma-separated list of STUN servers (in format stun:host:port) and TURN servers to use (in format username:credential@turn:host:port) (i.e. username:credential@turn:global.turn.twilio.com:3478?transport=tcp) (only valid for weron://) (default "stun:stun.l.google.com:19302")
  -weron-signaler string
    	Signaler address (only valid for weron://) (default "wss://weron.up.railway.app/")

Acknowledgements

Contributing

To contribute, please use the GitHub flow and follow our Code of Conduct.

To build and start a development version of panrpc locally, run the following:

$ git clone https://github.com/pojntfx/panrpc.git

# For Go
$ cd panrpc/go
$ go run ./cmd/panrpc-example-tcp-server-cli/ # Starts the Go TCP example server CLI
# In another terminal
$ go run ./cmd/panrpc-example-tcp-client-cli/ # Starts the Go TCP example client CLI

# For TypeScript
$ cd panrpc/ts
$ npm install
$ npx tsx ./bin/panrpc-example-tcp-server-cli.ts # Starts the TypeScript TCP example server CLI
# In another terminal
$ npx tsx ./bin/panrpc-example-tcp-client-cli.ts # Starts the TypeScript TCP example client CLI

Have any questions or need help? Chat with us on Matrix!

License

panrpc (c) 2024 Felicitas Pojtinger and contributors

SPDX-License-Identifier: Apache-2.0

About

Language-, transport- and serialization-agnostic RPC framework with remote closure support that allows exposing and calling functions on both clients and servers.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages