Open sourcing rpc_ts, an RPC framework for TypeScript

aiden.ai
aiden.ai
Published in
8 min readApr 1, 2019

Written by Hadrien Chauvin — Software engineer at aiden.ai.

You can find the source for rpc_ts on GitHub, and an example project using rpc_ts (a real-time chat room) here.

rpc_ts is a framework for type-safe Remote Procedure Calls (RPC) in TypeScript. At aiden.ai, we use it in production as a “first line of communication” between the frontend and the backend of our web app.

In a nutshell, rpc_ts provides an RPC protocol with the following features:

  1. It is tailored to isomorphic web applications written in TypeScript with a Minimal Viable Product mentality — more below.
  2. It doesn’t rely on a Domain-Specific Language: all the definitions are written in TypeScript, code generation is avoided, and you use the same code completion, same plugins, and same IDE capabilities you are accustomed to when working with TypeScript.
  3. It implements the grpc-web+json protocol (an adaptation of the popular gRPC protocol for the web), so we are not reinventing anything here.
  4. Type safety/data validation comes from TypeScript.
  5. It provides both unary method calls and half-duplex communication for push notifications and real-time feeds.

We discovered that this approach shortens the development cycle of web applications written in isomorphic TypeScript. We believe rpc_ts fills an important gap in the RPC ecosystem.

The four layers (validation, serialization, routing, and transport) of an RPC system, and the choices we made for rpc_ts.

In this post, I’m going to compare our design to other solutions out there and go through the rationale for coming up with rpc_ts.

Agility

rpc_ts was made for the startup engineer who prioritizes time to market, readability and correctness.

rpc_ts was developed with agility first and foremost in mind. It does not explicitly address scalability or performance (although this should not be a problem, really), but helps writing Minimal Viable web apps, decreasing time to market, and improving the developer experience without compromising correctness. rpc_ts was made for the startup engineer who recognizes that using one language is always preferable to using many, that proven technologies should be favoured over new, shiny ones, and that messing with the toolchain to enable code generation with custom DSLs (Domain-Specific Languages) is a time pit. All the design decisions detailed below are informed by these constant, unrelenting concerns.

What goes in an RPC protocol and where rpc_ts fits

To further detail the choices that went into rpc_ts, let’s try to locate it in the space of RPC protocols. Briefly, their design mainly revolves around four concerns:

  • Serialization/codec choice — How to serialize the data?
  • Data validation — How to validate the data?
  • Routing — How to know which procedure to call, on which server? How are errors reported?
  • Transport — Which “transport protocol” is used under the hood?

In the case of rpc_ts, we provide defaults that we think make sense in the case of an isomorphic web app written in TypeScript. (However, our architecture is modular enough to accommodate for other choices.)

Data serialization

Data serialization formats come in many flavours, but JSON is arguably the most popular one today.

Data serialization formats come in many flavours, as serialization has many objectives incompatible with each other:

  • Language-specific integration vs. broader language support — Some formats closely map the type systems of target languages (e.g., Pickle for Python, RData for the R Statistical Language, native serialization for Java), others are made with language interoperability in mind (Protocol Buffers, the Thrift binary protocol). In the case of language-specific integration, data serialization can be dealt with runtime type reflection or walking through the Abstract Syntax Tree. On the other hand, as language interoperability usually entails defining the data schema with an Interface Definition Language, integration in this latter case usually proceeds from code generation.
  • Human-readability vs. speed/payload size — The payload for some protocols is human-readable (e.g., CSV, JSON, XML) and binary for others. Binary encoding speeds up serialization/deserialization and lowers payload size. In this category, we find CBOR, BSON, Apache Avro, Protocol Buffers, the Thrift binary protocol, etc.
  • Relying on a broad ecosystem vs. advocating an improved format — JSON and XML serializers are broadly available in all mainstream languages, as are HTTP rest clients and servers. In contrast, binary protocols are less broadly supported. As a consequence, even though binary protocols might offer some advantages, it is sometimes more judicious to stick to more common, less efficient protocols and to benefit from a larger ecosystem.

In the case of rpc_ts, we made the choice to use JSON by default:

  • Language-specific integration — JSON stands for JavaScript Object Notation. It is aptly named as everything in JavaScript that is not a function, part of a class prototype or recursive can be JSON-serialized. Therefore, JSON is ideal for JavaScript.
  • Human readability — It is possible to read JSON in the network tab of a browser’s devtools, to pretty-print JSON in the browser console, in the server logs, …
  • Codec speed — It is difficult to be more efficient than JSON serialization in the browser, as JSON.parse and JSON.stringify benefit from native implementations. Moreover, network latency dominates by a large margin in the context of browser-server interactions, and the duration of the serialization itself stays inconsequential.
  • Payload size — Payload size is reduced by compression. With HTTP2, over a unique TCP connection, we are even using a single compression context, mutualizing the same schema between repeated payloads.
  • Broad ecosystem — JSON is arguably the most popular serialization format today.

Data validation

We believe making data validation the responsibility of the RPC protocol improves readability and shortens the development cycle.

Type systems have two responsibilities:

  • data representation — How is the data represented in memory?
  • data validation — What is the set of acceptable values?

Data validation can go beyond ensuring the validity of the data representation (e.g., a string cannot be stored where an integer is expected). For instance, while UUIDs, RFC3339 date-times and URLs can be stored as strings (data representation), the sets of acceptable values differ (data validation). Likewise, an array and a non-empty array can be validated differently, although the underlying representation could in both cases be a memory slice.

In a type system viewed as a means of data validation, the type of a variable is a concise specification of the set of values this variable can take. One popular way to implement this in practice is through (structural) algebraic data typesand newtypes/brands/nominal typing. With such an approach, all the validation for some data can be incorporated in their type, meaning: (1) exceptions should (in theory) always be I/O related, (2) a function’s requirements now appear in its signature, (3) you can perform hypothesis testing a.k.a. quick checks solely based on a function’s signature instead of having to maintain separate filters.

We believe making data validation the responsibility of the RPC protocol improves readability and shortens the development cycle. However, approaches such as protoc-gen-validate and JSON-schema feel ad hoc as their treatment of types is not systematic. In the case of rpc_ts, we decided to piggy-back on the TypeScript type system: it provides algebraic data types out of the box and the newtype pattern can be “emulated,” thus meeting our requirements for systematic data validation at the RPC protocol level. Furthermore, “compile-time” validation, before full type erasure, is augmented with runtime validation through type reflection.

TypeScript provides algebraic data types out of the box and the newtype pattern can be “emulated.” This gist shows how, using these two constructs, we define rpc_ts services.

Routing

rpc_ts implements the grpc-web protocol (let’s not reinvent the wheel).

In routing, I include the following decisions:

  • Endpoint naming — In gRPC and Thrift, an “endpoint” is designated by a service name and a method name. In REST, the endpoint pairs an HTTP path (e.g., /user/1239df29) with a verb/method (GET, POST, PATCH, DELETE, …).
  • Error reporting — In REST, HTTP status codes would be used, in gRPC a separate set of error codes. In both cases, error reporting can be refined with custom error messages. Thrift adopts a similar approach, although its error handling is based on a more general exception mechanism.
  • Between-server routing — Proxies such as Linkerd and Envoy can perform complex gRPC load balancing based on the service and method names, and Thrift has Finagle for service discovery.

Following the grpc-web specification, rpc_ts endpoints are HTTP POST methods with paths of the form /servicePrefix/methodName (by convention, servicePrefix doubles as the service name), and we use gRPC error codes with optional custom messages for error reporting.

Between-server routing is not really needed when a web app frontend talks to a backend, and rpc_ts does not provide such proxying.

Transport

In the RPC world, transport is almost always based on the TCP protocol (but Unix sockets can be seen). A layer can be added for multiplexing (e.g., HTTP2).

As we follow the grpc-web protocol, rpc_ts uses HTTP2 framing as partially exposed by WHATWG-Fetch.

rpc_ts does not use any Domain Specific Language

Static typing with DSLs requires code generation, and code generation is hard and should only be used as a last resort:

  • Maintainability — You need to write and maintain the code generator.
  • Documentation of the generated code — You will probably always need to look at the generated code to understand what’s going on.
  • Documentation of the Domain Specific Language — For instance, in some cases, is using an annotation-based DSL in Java really better than writing statements?
  • Toolchain — The only sane way I know to generate code is to use a build system such as Bazel. Otherwise, you have to cope with a ballet of checked-in generated code and unmanageable code generation chains. However, Bazel is a beast geared towards sizeable teams, especially if you venture outside the Java or C++ toolchains (for other languages you would need a considerable amount of customization).
  • IDE support — If you use Bazel or similar build systems, you reduce the ability of your IDE to provide intelligent code completion without a lot of customization. Moreover, unless you develop your own code completion, linter, and other supporting tools for your DSL that integrate with your IDE of choice, you deprive yourself of the much more productive development environment offered around mainstream languages.

Code generation cannot be avoided for cross-language RPC protocols with some degree of data validation. However, rpc_ts, which is TypeScript only, can be leaner as it relies on TypeScript’s full type erasure, JavaScript’s first-class JSON support, and, for additional security, runtime type reflection capabilities that we will open source soon. We thus made the decision to keep our entire RPC system within TypeScript.

Common questions

What about the data backend?

rpc_ts is not a generalist RPC protocol. It focuses solely on TypeScript in the context of an isomorphic web application. Communication with a data backend, often cross-language, falls out of the scope.

What about GraphQL?

We feel that GraphQL offers a solution to a problem teams developing isomorphic web applications do not have.

GraphQL offers the promise, among other things, to decouple the frontend team from the backend team. In this scenario, the frontend team could perform graph queries that more closely mirror the shape of the app without having to update the backend team.

This approach is difficult to work out in practice: see just the difficulty of optimizing an SQL join that would be the natural SQL for some queries, or in a similar vein, the efforts one must put to implement batching. In any case, the underlying concern is irrelevant in the context of a full stack team: the same developers would work both on the frontend and the backend, using the same language and shared concepts. Moreover, GraphQL comes with a DSL, code generation, and additional costs (outlined in a previous section).

How can I implement authentication? Tracing?

With rpc_ts, authentication and tracing can be implemented using contexts. Contexts are added by context connectors and contain meta information that pertains to the remote nature of the procedure call.

The source code for rpc_ts is available on GitHub under the MIT license. Feel free to contribute.

--

--