@pojntfx/dudirekta
TypeScript icon, indicating that this package has built-in type declarations

0.6.1 • Public • Published

dudirekta

Logo

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/dudirekta TypeScript docs Matrix

Overview

dudirekta is a novel RPC framework with a unique feature: It allows exposing functions on both the client and server!

It enables you to ...

  • Call remote functions transparently: dudirekta makes use of reflection, so you can call functions as though they were local without defining your own protocol or generating code
  • Call functions on the client from the server: Unlike most RPC frameworks, dudirekta allows for functions to be exposed on both the server and the client, enabling its use in new usecases such as doing bidirectional data transfer without subscriptions or pushing information before the client requests it
  • Implement RPCs on any transport layer: By being able to work with any 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 dudirekta to build services that run in almost any environment, including the browser!
  • Use an encoding/decoding layer of your choice: Instead of depending on Protobuf or another fixed format for serialization, dudirekta can work with every serialization framework that implements the basic Marshal/Unmarshal interface, such as JSON or CBOR.
  • Pass closures and callbacks to RPCs: Thanks to its bidirectional capabilities, dudirekta can handle closures and callbacks transparently, just like with local function calls!

Installation

Library

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

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

There is also a TypeScript version for browser and Node.js support (without transparent support for closures); you can install it like so:

$ npm i -s @pojntfx/dudirekta

This README's documentation only covers the Go version. For the TypeScript version, please check out Hydrapp, it uses dudirekta in its examples; you can also find the complete package reference here: TypeScript docs as well as example in ts/dudirekta-example-websocket-client.ts.

durl Tool

In addition to the library, the CLI tool durl is also available; durl is like cURL or gRPCurl, but for dudirekta: A command-line tool for interacting with dudirekta servers.

Static binaries are available on GitHub releases.

On Linux, you can install them like so:

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

On macOS, you can use the following:

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

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

PS> Invoke-WebRequest https://github.com/pojntfx/dudirekta/releases/latest/download/durl.windows-x86_64.exe -OutFile \Windows\System32\durl.exe

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

Usage

TL;DR: Define the local and remote functions as struct methods, add them to a registry and link it with a transport

1. Define Local Functions

dudirekta 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
}

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 following limitations on which functions you can expose 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

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
}

And do the same for the client:

// client.go

type remote struct {
	Increment func(ctx context.Context, delta int64) (int64, error)
}

3. Add Functions to a Registry

For the server, you can now create the registry, which will expose its functions:

// server.go

registry := rpc.NewRegistry[remote, json.RawMessage](
	&local{},

	time.Second*10,
	context.Background(),
	nil,
)

And do the same for the client:

// client.go

registry := rpc.NewRegistry[remote, json.RawMessage](
	&local{},

	time.Second*10,
	context.Background(),
	nil,
)

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

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

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

5. Call the Functions

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

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

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,

	time.Second*10,
	context.Background(),
	nil,
)
service.ForRemotes = registry.ForRemotes

6. Using Closures and Callbacks

Because dudirekta 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{}

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

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

🚀 That's it! We can't wait to see what you're going to build with dudirekta.

Reference

Examples

To make getting started with dudirekta 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 CBOR
uint8 93634 123122
uint64 94733 117978
uint32 94182 116764
uint16 94629 118126
uint 93584 122450
struct 90980 116290
string 87398 117085
slice 88604 117843
rune 94625 120375
int8 99581 133133
int64 93243 120311
int32 95189 122630
int16 94048 133136
int 107469 130494
float64 88636 113678
float32 92018 116722
byte 94230 125744
bool 88509 116449
array 89869 118470

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
JSON 98 MB/s
CBOR 1351 MB/s

Protocol

The protocol used by dudirekta 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 dudirekta is bidirectional, meaning that both the client and server can send and receive both types of messages to each other.

Reference

$ durl --help
Like cURL, but for dudirekta: Command-line tool for interacting with dudirekta servers

Usage of durl:
	durl [flags] <(ws|wss|tcp|tls)://host:port/function> <[args...]>

Example:
	durl wss://jarvis.fel.p8.lu/ToggleLights '["token", { "kitchen": true, "bathroom": false }]'

Flags:
  -cert string
    	TLS certificate
  -key string
    	TLS key
  -listen
    	Whether to connect to remotes by listening or dialing
  -timeout duration
    	Time to wait for a response to a call (default 10s)
  -verbose
    	Whether to enable verbose logging
  -verify
    	Whether to verify TLS peer certificates (default true)

Acknowledgements

Contributing

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

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

$ git clone https://github.com/pojntfx/dudirekta.git
$ cd dudirekta
$ go run ./cmd/dudirekta-example-tcp-server/ # Starts the TCP example server
# In another terminal
$ go run ./cmd/dudirekta-example-tcp-client/ # Starts the TCP example client

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

License

dudirekta (c) 2023 Felicitas Pojtinger and contributors

SPDX-License-Identifier: Apache-2.0

/@pojntfx/dudirekta/

    Package Sidebar

    Install

    npm i @pojntfx/dudirekta

    Weekly Downloads

    0

    Version

    0.6.1

    License

    AGPL-3.0

    Unpacked Size

    893 kB

    Total Files

    70

    Last publish

    Collaborators

    • pojntfx