Typescript + NodeJS gRPC using Thrift

How we generate Typescript from Thrift to build NodeJS gRPC servers + clients that communicate using the Thrift protocol.

Josh Stern
Compass True North

--

Background

The Compass microservice architecture communicates using gRPC. Our interface definition language (IDL) of choice is Thrift and our message serialization uses the Thrift protocols. We decided to mix the two because we were looking for the advantages of gRPC without forcing a full rewrite of our many pre-existing Thrift definitions. Supporting a new language when using the Thrift IDL + protocols through Google’s gRPC framework presents a few unique challenges. This post is intended to explore those challenges, our solutions, and the open-source projects that helped us fully support Typescript.

Before embarking on what would be a two-month adventure spanning Thrift AST’s, the experimental Typescript compiler API, and the inner workings of Google’s NodeJS gRPC framework we wanted to answer two essential questions.

Why do we need it?

We identified two compelling reasons for supporting Typescript in our stack:

  • Enabling NodeJS + Typescript in an RPC-centric backend allows us to share javascript logic already written by engineers across our infrastructure and leverage npm to manage our internal/third-party dependencies.
  • Supporting NodeJS would provide a familiar runtime for many engineers; this would enable them to quickly switch contexts and make backend contributions confidently without the overhead of learning a new language.

What do we need to make it work?

For any language to function in a gRPC + Thrift environment there are three required components:

  • A Thrift IDL to target language generator.
  • A set of generated thrift types which describe all of the thrift definitions in our target language. Any definitions that will contain data must be generated in a Thrift-(de)serializable way using the Thrift protocol interface.
  • A set of generated gRPC adapters that sit between the generated Thrift definitions and the gRPC framework API.

Thrift IDL to Typescript Generator

We need a tool that can read a set of thrift files, parse them to a well-defined AST, and render that AST to Typescript. During the research phase of the project we were incredibly lucky to find thrift-typescript, an open-source project which provided a great starting point for our work.

thrift-typescript is specialized for generating Thrift servers so we created a fork and adapted it to our needs in the following ways.

Taking a More Generic Approach to Generation

First, we expanded the API to support external renderers which in turn allowed our domain-specific renderers to live alongside the environment they are rendering for. Next, we discovered parsing large Thrift projects represents a non-trivial amount of work so we supported multiple renderers in a single run. Finally, we developed support for stateful renderers that can embed configuration details used by both the renderer and the entire generator process.

Our renderers are built using the Typescript compiler API which provides incredibly fine-grained control over the generated typescript.

Generator Architecture
Generator Architecture

Building for the Future

As we made the API more flexible we also worked to decouple the parsing process from the rest of the generation. Separating those concerns allows us to easily swap out components without breaking the process as a whole. For example if we wanted to load a pre-processed Thrift AST or swap out the parsing + rendering entirely we can do so without having to do a large-scale rewrite of the core generation logic.

Thrift-(de)serializable Typescript Definitions

Once our generation framework is functional we then need renderers for Thrift Typescript definitions. These definitions allow us to encode/decode Thrift binary messages over the wire. Luckily we were able to reuse parts of the open-source project’s apache-thrift renderers. Specifically, the parts that generate Typescript classes with a static read() and non-static write() method. Those methods provide a standard interface for object serialization/deserialization.

We will be using the following calculator service definition for all of the generation examples:

Calculator Service Thrift Definition

NOTE: All generated code examples are edited for demonstration clarity and do not represent the true generator output.

Generated Calculator Request Class

After adapting the Apache Thrift renderers to our needs we were able to add support for struct constants (const CalculatorRequest calc = {“a“: 10, “b“: 30};) and nested container types (list<map<string, i32>>).

gRPC Adapters

Once we have well-defined types that can read/write messages using any of the thrift-supported protocols we then need to build the bridge between those types and the gRPC NodeJS API.

The official NodeJS gRPC guide only covers building a server and client defined using protobufs. We were able to dig into the source code to understand how messages are sent and what our generators will need to add.

gRPC Service Adapter

Our goal was to abstract away the gRPC-specific service API and allow implementers to focus exclusively on fulfilling the interface they defined in Thrift. The gRPC NodeJS source showed us there are two essential components for building a service:

Service Definition — Paths, streams, and (de)serialization
The service definition is a simple map from method names to method definitions. Each method definition has four essential parts:

  • The gRPC method path which defines the path that routes to the service.
  • The stream booleans which control whether requests/responses should be treated as streams.
  • The serialization methods which receive an object and return a raw buffer of data to send.
  • The deserialize methods which receive a raw buffer and return a well-defined object.

All the information we require for this map is embedded in the Thrift definitions so it can be entirely generated:

  • The path can follow a standard naming convention.
  • The stream booleans can be set based on service method definitions.
  • The serialization and deserialization methods can be generated using our Thrift Typescript types via their read and write methods.

Using the same calculator example from earlier, the generated Calculator service definition will be:

Service Implementation — Request handling and business logic

The service implementation is a map from the method names to method implementations. A unary NodeJS gRPC method implementation is a simple method with a standard call signature. For simplicity we’ll go over the unary call but the same concepts can be applied to all method types. Each call has two parameters:

  • call — A unary call wrapper object that contains the request.
  • callback — A callback used to send a constructed response back to the client.

To avoid forcing implementers to interact with this gRPC-specific interface we created a wrapper class and factory method to make instantiation consistent and simple. The wrapper class takes our Thrift-generated Typescript interface and adapts it to the gRPC API by correctly unwrapping request objects, calling the handler business logic, and passing the response to the callback.

Generated CalculatorImplementation class and factory:

With these two building blocks we can now build a fully-functional Thrift + gRPC server!

Now that we have a fully functional server (in only 10 lines of code!) we will want to mirror this work for our clients.

gRPC Client Adapter

The NodeJS gRPC library exposes a two step process for initializing clients:

  1. Create the constructor — Passing our service definition and name into their function for making generic client constructors.
  2. Instantiate the client That constructor can then be passed credentials, a target address, and other options to create a gRPC client.

Once again our goal was to abstract away the gRPC-specific logic and expose a simple interface for the client consumer. Similar to the service implementation, we created a wrapper class for the gRPC client and a factory function to unify the instantiation process. The gRPC client requires a callback and error handling pattern that we wrap in the promise-based async/await pattern. The wrapper also provides a place for system-wide standards to be enforced across all clients.

Generated CalculatorClient class and factory:

Now we can create clients that communicate with our gRPC servers.

Service Scaffolding

With a flexible generator framework, serializable definitions, servers, and clients all ready to go we found ourselves in the perfect position to add one last feature that makes the implementation process even easier. By walking our Thrift service definitions we are able to output a minimal scaffold that runs and serves default data. With this last enhancement service implementers can truly focus on building effective and safe business logic.

Conclusion

Although this project fulfills a very specific need, the exercise of expanding an open-source project, exploring the inner workings of the gRPC library, and focusing on a simple developer experience was a valuable experience. It’s also important to acknowledge that enabling a new language in our large-scale infrastructure would have been impossible without the incredible work already done by our infrastructure engineers. We are all looking forward to see what can be built.

A huge thank you to Joe Schmitt who shared the work with me and whose leadership was integral to its success.

--

--