diff --git a/assets/highlight.css b/assets/highlight.css index b279539..628f1ff 100644 --- a/assets/highlight.css +++ b/assets/highlight.css @@ -13,12 +13,16 @@ --dark-hl-5: #4EC9B0; --light-hl-6: #AF00DB; --dark-hl-6: #C586C0; - --light-hl-7: #001080; - --dark-hl-7: #9CDCFE; - --light-hl-8: #098658; - --dark-hl-8: #B5CEA8; - --light-hl-9: #0451A5; - --dark-hl-9: #9CDCFE; + --light-hl-7: #098658; + --dark-hl-7: #B5CEA8; + --light-hl-8: #001080; + --dark-hl-8: #9CDCFE; + --light-hl-9: #EE0000; + --dark-hl-9: #D7BA7D; + --light-hl-10: #000000; + --dark-hl-10: #C8C8C8; + --light-hl-11: #0070C1; + --dark-hl-11: #4FC1FF; --light-code-background: #FFFFFF; --dark-code-background: #1E1E1E; } @@ -34,6 +38,8 @@ --hl-7: var(--light-hl-7); --hl-8: var(--light-hl-8); --hl-9: var(--light-hl-9); + --hl-10: var(--light-hl-10); + --hl-11: var(--light-hl-11); --code-background: var(--light-code-background); } } @@ -48,6 +54,8 @@ --hl-7: var(--dark-hl-7); --hl-8: var(--dark-hl-8); --hl-9: var(--dark-hl-9); + --hl-10: var(--dark-hl-10); + --hl-11: var(--dark-hl-11); --code-background: var(--dark-code-background); } } @@ -62,6 +70,8 @@ --hl-7: var(--light-hl-7); --hl-8: var(--light-hl-8); --hl-9: var(--light-hl-9); + --hl-10: var(--light-hl-10); + --hl-11: var(--light-hl-11); --code-background: var(--light-code-background); } @@ -76,6 +86,8 @@ --hl-7: var(--dark-hl-7); --hl-8: var(--dark-hl-8); --hl-9: var(--dark-hl-9); + --hl-10: var(--dark-hl-10); + --hl-11: var(--dark-hl-11); --code-background: var(--dark-code-background); } @@ -89,4 +101,6 @@ .hl-7 { color: var(--hl-7); } .hl-8 { color: var(--hl-8); } .hl-9 { color: var(--hl-9); } +.hl-10 { color: var(--hl-10); } +.hl-11 { color: var(--hl-11); } pre, code { background: var(--code-background); } diff --git a/classes/Registry.html b/classes/Registry.html index c2a1be0..672341f 100644 --- a/classes/Registry.html +++ b/classes/Registry.html @@ -14,7 +14,10 @@
-Create a new registry
+Class with local RPCs to expose
+Class with remote RPC placeholders to implement
+Optional
options: IOptionsOptional
options: IOptionsConfiguration options
+Iterate over list of connected remotes
+Function to execute for each remote
+Expose local RPCs and implement remote RPCs via a message-based transport
+Stream to write requests to
+Stream to write responses to
+Stream to read requests from
+Stream to read responses from
+Function to marshal nested values with
+Function to unmarshal nested values with
+Stream to write messages to
+Stream to read messages from
+Function to marshal nested values with
+Function to unmarshal nested values with
+Language-, transport- and serialization-agnostic RPC framework with remote closure support that allows exposing and calling functions on both clients and servers.
panrpc is a novel RPC framework with a unique feature: It allows exposing functions on both the client and server!
+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 ...
io.ReadWriteCloser
such as TCP, WebSocket or WebRTC with the Stream-Oriented API, or any message-based transport such as Redis or NATS with the Message-Oriented API, you can use panrpc to build services that run in almost any environment, including the browser!Marshal
/Unmarshal
interface, such as JSON or CBOR.You can add panrpc to your Go project by running the following:
-go get github.com/pojntfx/panrpc/...@latest
+You can add panrpc to your Go project by running the following:
+$ go get github.com/pojntfx/panrpc/...@latest
-For 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 i -s @pojntfx/panrpc
+For 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
@@ -62,461 +59,423 @@ 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
+$ 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
+$ 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.
-
- Usage
+
+ Tutorial
+
+
+
+ Go
-TL;DR: Define the local and remote functions as struct methods, add them to a registry and link it with a transport
+Just looking for sample code? Check out the sources for the example coffee machine server and coffee machine client/remote control.
-
- 1. Define Local Functions
+
+ 1. Choosing a Transport and Serializer
-panrpc uses reflection to create the glue code required to expose and call functions. Start by defining your server's exposed functions like so:
-// server.go
type local struct {
counter int64
}
func (s *local) Increment(ctx context.Context, delta int64) (int64, error) {
log.Println("Incrementing counter by", delta, "for peer with ID", rpc.GetRemoteID(ctx))
return atomic.AddInt64(&s.counter, delta), nil
}
+
+ 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
-In your client, define the exposed functions like so:
-// client.go
type local struct{}
func (s *local) Println(ctx context.Context, msg string) error {
log.Println("Printing message", msg, "for peer with ID", rpc.GetRemoteID(ctx))
fmt.Println(msg)
return nil
}
+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 io.ReadWriter
can be used with the panrpc LinkStream
API. If you want to use a message broker like 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
-The following limitations on which functions you can expose exist:
+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 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:
-- Functions must have
context.Context
as their first argument
-- Functions can not have variadic arguments
-- Functions must return either an error or a single value and an error
+- 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
+
+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,
}
clients := 0
registry := rpc.NewRegistry[struct{}, json.RawMessage](
service,
ctx,
&rpc.Options{
OnClientConnect: func(remoteID string) {
clients++
log.Printf("%v remote controls connected", clients)
},
OnClientDisconnect: func(remoteID string) {
clients--
log.Printf("%v remote controls connected", clients)
},
},
)
}
+
+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:
+
+ Expand boilerplate code snippet
-
- 2. Define Remote Functions
-
-Next, define the functions exposed by the client to the server using a struct without method implementations:
-// server.go
type remote struct {
Println func(ctx context.Context, msg string) error
}
+// cmd/coffee-machine/main.go
import (
"encoding/json"
"net"
"net/http"
"github.com/pojntfx/panrpc/go/pkg/rpc"
"nhooyr.io/websocket"
)
func main() {
// ...
lis, err := net.Listen("tcp", "127.0.0.1:1337")
if err != nil {
panic(err)
}
defer lis.Close()
log.Println("Listening on", lis.Addr())
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)
}
}()
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()
encoder := json.NewEncoder(conn)
decoder := json.NewDecoder(conn)
go func() {
if err := registry.LinkStream(
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)
}
}
-And do the same for the client:
-// client.go
type remote struct {
Increment func(ctx context.Context, delta int64) (int64, error)
}
+
+
+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. Add Functions to a Registry
+
+
+ 3. Creating a Client
-For the server, you can now create the registry, which will expose its functions:
-// server.go
registry := rpc.NewRegistry[remote, json.RawMessage](
&local{},
context.Background(),
nil,
)
+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)
}
-And do the same for the client:
-// client.go
registry := rpc.NewRegistry[remote, json.RawMessage](
&local{},
context.Background(),
nil,
)
+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()
clients := 0
registry := rpc.NewRegistry[coffeeMachine, json.RawMessage](
&struct{}{},
ctx,
&rpc.Options{
OnClientConnect: func(remoteID string) {
clients++
log.Printf("%v coffee machines connected", clients)
},
OnClientDisconnect: func(remoteID string) {
clients--
log.Printf("%v coffee machines connected", clients)
},
},
)
}
-Note the second generic parameter; it is the type that should be used for encoding nested messages. For JSON, this is typically json.RawMessage
, for CBOR, this is cbor.RawMessage
. Using such a nested message type is recommended, as it leads to a faster encoding/decoding since it doesn't require multiple encoding/decoding steps in order to function, but using []byte
(which will use multiple encoding/decoding steps) is also possible if this is not an option (for more infromation, see Protocol).
+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:
+
+ Expand boilerplate code snippet
-
- 4. Link the Registry to a Transport and Serializer
-
-Next, expose the functions by linking them to a transport. There are two available transport APIs; the Stream-Oriented API (which is useful for stream-like transports such as TCP, WebSockets, WebRTC or anything else that provides an io.ReadWriteCloser
), and the Message-Oriented API (which is useful for transports that use messages, such as message brokers like Redis, UDP or other packet-based protocols). In this example, we'll use the stream-oriented API; for more information on using the m, meaning it can run in the browser!essage-oriented API, see Examples.
-Similarly so, as mentioned in Add Functions to a Registry, it is possible to use almost any serialization framework you want, as long as it can provide the necessary import interface. In this example, we'll be using the encoding/json
package from the Go standard library, but in most cases, a more performant and compact framework such as CBOR is the better choice. See Benchmarks for usage examples with other serialization frameworks and a performance comparison.
-Start by creating a TCP listener in your main
func (you could also use WebSockets, WebRTC or anything that provides a io.ReadWriteCloser
) and passing in your serialization framework:
-// server.go
lis, err := net.Listen("tcp", "localhost:1337")
if err != nil {
panic(err)
}
defer lis.Close()
for {
func() {
conn, err := lis.Accept()
if err != nil {
return
}
go func() {
defer func() {
_ = conn.Close()
if err := recover(); err != nil {
log.Printf("Client disconnected with error: %v", err)
}
}()
encoder := json.NewEncoder(conn)
decoder := json.NewDecoder(conn)
if err := registry.LinkStream(
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)
}
}()
}()
}
+// cmd/remote-control/main.go
import (
"encoding/json"
"github.com/pojntfx/panrpc/go/pkg/rpc"
"nhooyr.io/websocket"
)
func main() {
// ...
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")
encoder := json.NewEncoder(conn)
decoder := json.NewDecoder(conn)
if err := registry.LinkStream(
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
-For the client, do the same, except this time connect to the server by dialing it:
-// client.go
conn, err := net.Dial("tcp", *addr)
if err != nil {
panic(err)
}
defer conn.Close()
encoder := json.NewEncoder(conn)
decoder := json.NewDecoder(conn)
if err := registry.LinkStream(
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)
}
+Similarly so, the coffee machine server should output the following:
+1 remote controls connected
+
-
- 5. Call the Functions
+
+
+ 4. Calling the Server's RPCs from the Client
-Now you can call the functions exposed on the server from the client and vise versa. For example, to call Println
, a function exposed by the client from the server:
-// server.go
if err := registry.ForRemotes(func(remoteID string, remote remote) error {
return remote.Println(ctx, "Hello, world!")
}); err != nil {
panic(err)
}
+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)
}
}
}()
// ...
}
-Or to call the Increment
function exposed by the server on the client:
-// client.go
if err := registry.ForRemotes(func(remoteID string, remote remote) error {
new, err := remote.Increment(ctx, 1)
if err != nil {
return err
}
log.Println(new)
}); 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
-By passing the ForRemotes()
method to the local service itself, you can also access remote functions in the other direction:
-// server.go
type local struct {
counter int64
ForRemotes func(cb func(remoteID string, remote R) error) error
}
func (s *local) Increment(ctx context.Context, delta int64) (int64, error) {
remoteID := rpc.GetRemoteID(ctx)
if err := registry.ForRemotes(func(candidateID string, remote remote) error {
if candidateID == remoteID {
return peer.Println(ctx, fmt.Sprintf("Incrementing counter by %v", delta))
}
}); err != nil {
return -1, err
}
return atomic.AddInt64(&s.counter, delta), nil
}
// In `main`:
service := &local{}
registry := rpc.NewRegistry[remote, json.RawMessage](
service,
context.Background(),
nil,
)
service.ForRemotes = registry.ForRemotes
+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.
+
+
-
- 6. Using Closures and Callbacks
+
+ 5. Calling the Client's RPCs from the Server
-Because panrpc is bidirectional, it is possible to pass closures and callbacks as function arguments, just like you would locally. For example, on the server:
-// server.go
type local struct{}
func (s *local) Iterate(
ctx context.Context,
length int,
onIteration func(i int, b string) (string, error),
) (int, error) {
for i := 0; i < length; i++ {
rv, err := onIteration(i, "This is from the callee")
if err != nil {
return -1, err
}
log.Println("Closure returned:", rv)
}
return length, nil
}
type remote struct{}
+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{},
ctx,
&rpc.Options{
OnClientConnect: func(remoteID string) {
clients++
log.Printf("%v coffee machines connected", clients)
},
OnClientDisconnect: func(remoteID string) {
clients--
log.Printf("%v coffee machines connected", clients)
},
},
)
// ...
}
+
+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,
ctx,
&rpc.Options{
OnClientConnect: func(remoteID string) {
clients++
log.Printf("%v remote controls connected", clients)
},
OnClientDisconnect: func(remoteID string) {
clients--
log.Printf("%v remote controls connected", clients)
},
},
)
// ...
}
+
+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;
}
-And the client:
-// client.go
type local struct{}
type remote struct {
Iterate func(
ctx context.Context,
length int,
onIteration func(i int, b string) (string, error),
) (int, error)
}
+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
-When you call peer.Iterate
, you can now pass in a closure:
-// client.go
if err := registry.ForRemotes(func(remoteID string, remote remote) error {
length, err := remote.Iterate(ctx, 5, func(i int, b string) (string, error) {
log.Println("In iteration", i, b)
return "This is from the caller", nil
})
if err != nil {
return err
}
log.Println(length)
}); err != nil {
panic(err)
}
+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
-🚀 That's it! We can't wait to see what you're going to build with panrpc.
+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.
+
+
-
- Reference
+
+ 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
-
- Examples
+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 that 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
+
+🚀 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. 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
-To make getting started with panrpc easier, take a look at the following examples:
-
-- Transports
-- TCP (Stream-Oriented API)
-
-- UNIX Socket (Stream-Oriented API)
-
-stdin/stdout
Pipe (Stream-Oriented API)
-
-- WebSocket (Stream-Oriented API)
-
-- WebRTC (Stream-Oriented API)
-
-- Redis (Message-Oriented API)
-
-
-
-- Callbacks
-
-- Closures
-
-- Benchmarks
-- Go Requests/Second Benchmark Server CLI Example
-- Go Requests/Second Benchmark Client CLI Example
-- TypeScript Requests/Second Benchmark Server CLI Example
-- TypeScript Requests/Second Benchmark Client CLI Example
-- Go Throughput Benchmark Server CLI Example
-- Go Throughput Benchmark Client CLI Example
-- TypeScript Throughput Benchmark Server CLI Example
-- TypeScript Throughput Benchmark Client CLI Example
-
-
-
+
+Just looking for sample code? Check out the sources for the example coffee machine server and coffee machine client/remote control.
+
-
- Benchmarks
+
+ 1. Choosing a Transport and Serializer
-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
+
+ 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 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 {
constructor(private supportedVariants: string[], private waterLevel: number) {
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;
}
}
+
-This is measured by calling RPCs with the different data types as the arguments.
+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
+
-
-
-
-
-
-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
+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:
+
+ Expand boilerplate code snippet
+
+// coffee-machine.ts
import { JSONParser } from "@streamparser/json-whatwg";
import { WebSocketServer } from "ws";
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 encoder = new WritableStream({
write(chunk) {
socket.send(JSON.stringify(chunk));
},
});
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();
});
registry.linkStream(
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;
}
}
+
-This is measured by calling an RPC with []byte
as the argument.
+Placeholder methods must have IRemoteContext
instead of ILocalContext
as their first argument.
-
-
-
-
-
-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
}
+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");
},
}
);
-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": ""
}
}
+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:
+
+ Expand boilerplate code snippet
+
+// remote-control.ts
import { JSONParser } from "@streamparser/json-whatwg";
import { WebSocket } from "ws";
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 encoder = new WritableStream({
write(chunk) {
socket.send(JSON.stringify(chunk));
},
});
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();
});
registry.linkStream(
encoder,
decoder,
(v) => v,
(v) => v
);
console.log("Connected to localhost:1337");
-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.
+
-
- Reference
-
-$ 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 wss:// and tls://)
-tls-key string
TLS key (only valid for wss:// and tls://)
-tls-verify
Whether to verify TLS peer certificates (only valid for wss:// and tls://) (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/")
+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
+
+
-
- Acknowledgements
+
+
+ 4. Calling the Server's RPCs from the Client
-
-- zserge/lorca inspired the API design.
-
+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.
+
-
- Contributing
+
+
+ 5. Calling the Client's RPCs from the Server
-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
bun install
bun run ./bin/panrpc-example-tcp-server-cli.ts # Starts the TypeScript TCP example server CLI
# In another terminal
bun run ./bin/panrpc-example-tcp-client-cli.ts # Starts the TypeScript TCP example client CLI
+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;
-Have any questions or need help? Chat with us on Matrix!
+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.
+
+
-
- License
+
+ 6. Passing Closures to RPCs
-panrpc (c) 2023 Felicitas Pojtinger and contributors
-SPDX-License-Identifier: Apache-2.0
+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
Optional
responseOptional
onConst
Const
Const
Exposes local RPCs and implements remote RPCs
+