A minimal command line app to serve as an example of how create a node Typescript app which uses gRPC with Promises
And it tells you a joke.
This app uses two packages I've been working on:
grpc-promise-ts
is
used at runtime to convert the gRPC client stub generated by grpc-tools
to a client with an ES6 Promise API. Server functionality is in the works. It is a fork of the fantastic grpc_tools_node_protoc_ts
package.
grpc-promise-ts-generator-plugin
is a plugin for grpc-tool
's proto compiler used to generate Typescript definitions for Javascript
proto implementations. Optionally, it can also generates Typescript definitions for the Promise clients
grpc-promise-ts
creates at runtime.
With both we have a type safe way to call:
const response = await service.getSomething(request);
git clone https://github.com/rhinodavid/grpc-promise-ts-example-app && \
cd grpc-promise-ts-example-app && yarn && yarn build && yarn start
A great place to start is the blog post gRPC with Node.js and TypeScript by @idnan. It's a fantastic introduction but doesn't include a client implementation.
If you need details check out that post. Here I'll give you a broad overview of the steps used to build this app.
The protocol buffer (commonly, "proto") joke.proto
defines request and repsonse messages
between the client and the server, as well as what remote procedure calls (RPCs) exist on the server.
Next up: turn those protos into something we can use in Node.
A portion of the script to generate Javascript and Typescript definitions is reproduced here:
PROTOC="./node_modules/.bin/grpc_tools_node_protoc"
NODE_PLUGIN="./node_modules/.bin/grpc_tools_node_protoc_plugin"
TYPESCRIPT_PLUGIN="./node_modules/.bin/grpc-tools-node-typescript-promise-plugin"
${PROTOC} \
--js_out=import_style=commonjs,binary:"./${OUT_DIR}" \
--plugin=protoc-gen-grpc="${NODE_PLUGIN}" \
--grpc_out="./${OUT_DIR}" \
--plugin=protoc-gen-tspromise="${TYPESCRIPT_PLUGIN}" \
--tspromise_out=gen-promise-clients:"./${OUT_DIR}" \
-I "${PROTO_DIR}" \
"${PROTO_DIR}"/*.proto
This script is complex, so lets break it down.
First we define paths to symlinked binaries/scripts in our node_modules/.bin
folder.
grpc_tools_node_protoc
and grpc_tools_node_protoc_plugin
are installed by the grpc-tools
package.
grpc_tools_node_protoc
is a Javascript wrapper around the C++
protoc
compiler which translates protos into language implementations. The Javascript implementation is
jspb
,
whose output is configured by the --js_out
flag. There's some mention in the docs of an ES6 output, but that doesn't appear to be implemented as of commit c649397
in mid-2020, so you'll want to stick with commonjs
.
grpc_tools_node_protoc_plugin
also ships with grpc-tools
and generates Javascript server and client stubs
for the service
s specified in your protos. These stubs allow you to write Javascript which communicates to
the gRPC server and client binaries which do the actual communication.
grpc-tools-node-typescript-promise-plugin
is installed by
grpc-promise-ts
and generates Typescript definitions for
messages and services contained in the .proto
files.
If you plan to use grpc-promise-ts
to make Promise clients for your services, don't forget to include gen-promise-clients
in the output configuration argument of the plugin.
Now you're ready to run the script, which you can do with yarn build-protos
. Once it completes,
the jspb
folder will contain
a .js
implementation and a .d.ts
Typescript definition for the proto message, plus a second pair for the services.
These generated files are commited to the repo for easy reference on Github, but I generally do not include generated files in repositories for actual projects.
In your proto output directory, if you don't have a file like
joke_pb.js
but you do have a file likejokerequest.js
, make sure you haveimport_style=commonjs
in the--js_out
argument in the build script. You've built files with closure compiler imports, which nobody wants.
For each service added to the server you'll implement a handler. The handler contains a function for each RPC in the service which takes a request message and returns a response message.
Take a look at src/server/jokeHandler.ts
. This handler
implements getAJoke: (request: JokeRequest, response: JokeResponse) => void
. First, it gets the type
of joke the user wants from the request. Next, it gets a random joke of that type from the jokes it knows
and adds it to the response. Finally, it waits for a bit and then calls the callback with the response.
The server implementation is at src/server/server.ts
.
For each service you'd like the server to provide, call server.addService(<service>, <handler>)
to add it
to the server. Then bind the server to a host/port and start it.
The client is created by a helper function in
src/client/createJokePromiseClient.ts
.
To create the callback client use new JokeClient("<host>:<port>", credentials.createInsecure())
(when you eventually
add authentication to your server this will take a bit more configuration). The JokeClient
constructor is generated by the build-protos
script.
PROMISIFY IT
convertToPromiseClient
from the grpc-promise-ts
package creates a client with a Promise
API (make sure you
included gen-promise-clients
in your proto build script):
const promiseClient = convertToPromiseClient(callbackClient);
Now we can await reponses to the RPCs!
For unary RPCs (one request in, one response out) the signature is:
(request: TRequest, metadata?: Metadata, options?: Partial<CallOptions>) => TUnaryResult<TResponse>;
Let's look at TUnaryResult
. Its signature is:
interface TUnaryResult<TResponse> extends Promise<TResponse> {
getUnaryCall: () => grpc.ClientUnaryCall;
}
Since it extends Promise
we can await
its result like any other Promise
.
So getting a response looks like
const response = await promiseClient.getJoke(jokeRequest);
If you need access to the
grpc.ClientUnaryCall
you can remove the await
and call getUnaryCall
on the result:
const result = promiseClient.getJoke(jokeRequest);
const unaryCall = result.getUnaryCall();
try {
const response = await result;
} catch (e) {
console.error(`Promise rejected: ${e}`);
}
unaryCall.cancel(); // promise will reject if it was still pending
The joke CLI is implemented in src/app.ts
.
Most real apps won't start the client and server from the same process.
The logic steps the app are:
- Get a free port
- Start the server
- Start the client
- Use Inquirer to ask the user what kind of joke they want
- Build a request proto with that choice
- Send the request via the client to the server and awaits the response
- Show the response text to the user
- Shut down the client
- Shut down the server
Don't forget to shutdown your client, otherwise your node process will not exit (servers generally don't shut down).