Complete guide creating APIs using grpc-js and grpc-web with typescript

Photo by Tom Winckels on Unsplash

Server

stack and library used

  • nodejs
  • proto-loader: docs
  • grpc-js
  • swc: for transpile es module to common js and compile typescript to javascript
  • typescript

Proxy

stack used

  • envoy: for redirect grpc client’s request to grpc server so we can directly call grpc function on the client side (our web application).
  • docker: to run our envoy proxy.

Client

stack and library used

The how

Server

Okay, we already know the stack that we will be using. Let’s breakdown how to use it. First, let’s create the server. We will create a grpc server.

  • install proto-loader
  • install grpc-js: this is a javascript implementation of grpc server, there are many language to write grpc server, include go, java, etc. we will be using javascript for this guide.

There are two library that grpc officially introduces to write grpc server in javascript: grpc-node and grpc-js. However, grpc-node is deprecated and we suggested to use grpc-js instead. For detailed comparison between the two. Refer here.

  • install swc
  • install typescript
npm i typescript @tsconfig/node16 -D
  • add tsconfig.json file
{
"$schema": "https://json.schemastore.org/tsconfig",
"display": "Node 16",
"compilerOptions": {
"lib": ["es2021"],
"module": "commonjs",
"target": "es2021",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true
}
}
  • add proto file. for purpose of this guide, we will use this hello.proto file:
syntax = "proto3";
package guide.hello;
message GetHelloRequest {
string name = 1;
}
message GetHelloResponse {
string message = 1;
}
service HelloService {
rpc GetHello(GetHelloRequest) returns (GetHelloResponse) {};
}

We will not dive into proto file, that’s an explanation for another article. But for those of you who came from rest api, we can say proto like a contract for both server and client to follow. In above example, we define request and response for getUserRole.

  • generate typescript types. we will use proto-loader library to generating the types. This will useful for autocomplete and static type checking in our server file later. Run this command for auto generate the types.
node_modules/.bin/proto-loader-gen-types -I=./proto proto/*proto --outDir=proto/ proto/*.proto --grpcLib=@grpc/grpc-js

From command above, we ask the proto-loader to look for any proto files inside the proto folder and output the the result on proto folder. Also, we tell it to use grpc-js library.

  • Write the server. Create server.ts file in your root directory. This is the content of the file:
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
import { HelloServiceHandlers } from "./proto/guide/hello/HelloService";
import { ProtoGrpcType } from "./proto/hello";
const packageDefinition = protoLoader.loadSync("./proto/hello.proto", {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const handler: HelloServiceHandlers = {
// server handlers implementation...
GetHello: (call, callback) => {
return callback(null, {
message: `Hello, ${call.request.name}`
});
},
};
const proto = grpc.loadPackageDefinition(
packageDefinition
) as unknown as ProtoGrpcType;
const PATH = "127.0.0.1:9090";export function getServer(): grpc.Server {
const server = new grpc.Server();
server.addService(proto.guide.hello.HelloService.service, handler);
return server;
}
const server = getServer();
server.bindAsync(
PATH,
grpc.ServerCredentials.createInsecure(),
(err: Error | null, port: number) => {
if (err) {
console.error(`Server error: ${err.message}`);
} else {
console.log(`Server bound on port: ${port}`);
server.start();
}
}
);

Let’s explain this file bit by bit. First we import grpc-js to create a new grpc server instance and using proto-loader to read the proto file so our grpc server can access the service we define.

This line here,

import { HelloServiceHandlers } from "./proto/guide/hello/HelloService";
import { ProtoGrpcType } from "./proto/hello";

is a typescript type we get from proto-loader command we invoke earlier.

Notice that the name of the type import HelloServiceHandlers is automatically generated for us and refer to the name that we describe in our proto file, in this case is HelloService.

Next, we read the proto file so we can pass it as a service to the grpc server:

const packageDefinition = protoLoader.loadSync("./proto/hello.proto", {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});

After that we create the handler. This is where we process the logic from the incoming request and what we want to sent to the response. Ideally, we create a new controller file for this. But for now, this is reside here:

const handler: HelloServiceHandlers = {
// server handlers implementation...
GetHello: (call, callback) => {
return callback(null, {
message: `Hello, ${call.request.name}`
});
},
};

The next line until the last one is we create a new grpc server instance, add the service to it and starting it at the port 9090.

const proto = grpc.loadPackageDefinition(
packageDefinition
) as unknown as ProtoGrpcType;
const PATH = "127.0.0.1:9090";export function getServer(): grpc.Server {
const server = new grpc.Server();
server.addService(proto.guide.hello.HelloService.service, handler);
return server;
}
const server = getServer();
server.bindAsync(
PATH,
grpc.ServerCredentials.createInsecure(),
(err: Error | null, port: number) => {
if (err) {
console.error(`Server error: ${err.message}`);
} else {
console.log(`Server bound on port: ${port}`);
server.start();
}
}
);
  • Compile it to commonjs. Because our file is ending in typescript and we are using es6 module, import statement cannot be read from node unless we using .mjs file extension. We don’t want to do that here. So, we will use swc to do the compilation process to commonjs so the import statement can be read in nodejs environment. Add this content to .swcrc file:
{
"module": {
"type": "commonjs",
"strict": true,
"strictMode": true,
"lazy": false,
"noInterop": false
},
"jsc": {
"parser": {
"syntax": "typescript",
"tsx": false,
"decorators": false,
"dynamicImport": false
}
}
}

After that, run this command:

node_modules/.bin/swc ./server.ts -d output
  • Run the server. After that you can run the server with node output/server.js. You should see:
Server bound on port: 9090

Great, you successfully create and starting the grpc server. Now, lets setup the client.

Client

  • create a new next app:
yarn create next-app — typescript
  • install dependencies
yarn add google-protobuf grpc-web
  • add hello.proto file inside proto folder at our root directory:
syntax = "proto3";
package guide.hello;
message GetHelloRequest {
string name = 1;
}
message GetHelloResponse {
string message = 1;
}
service HelloService {
rpc GetHello(GetHelloRequest) returns (GetHelloResponse) {};
}
  • install protoc-gen-grpc-web and protoc. We will use both library to generate proto messages and the service client stub from our proto file. Please refer to here and here to download.
  • generate the proto message and service client stub. We will use this command:
protoc -I=./proto proto/*.proto \
--js_out=import_style=commonjs,binary:./grpc \
--grpc-web_out=import_style=typescript,mode=grpcweb:./grpc

As you can see, we specifying proto folder as our input and output the result in grpc folder. We also specifying the mode as grpcweb.

Before move on, we must create a folder named grpc manually at the root directory.

  • create a service. We will create a folder named service and inside it we are calling the grpc service we implemented at the server. This is the hello.ts file:
import { HelloServiceClient } from "../grpc/HelloServiceClientPb";
import { GetHelloRequest } from "../grpc/hello_pb";
const client = new HelloServiceClient('http://localhost:8080');
const request = new GetHelloRequest();
export const getHello = (name: string) => {
request.setName(name)
client.getHello(request, {}, (err, response) => {
console.log(request.getName());
console.log(response.toObject());
});
}

First, we create a new instance for the hello service and it listen to port 8080. This port will be specified later in the envoy proxy. Next, because the service we want to create have a request, so we create a new request instance for the service.

Lastly, we exporting the function that have responsibility to set the request, call the rpc function that we specify in proto file, and log the request and response.

If you look our hello.ts file, we see that our request and client import name are auto generated from our proto file and also corelated with it.

  • Let’s try to call our hello service from the client side. In our index.tsx file add:
useEffect(() => {
getHello("Bira");
}, []);

and run the next dev server: npm run dev. When we access it, we encounted error:

POST http://localhost:8080/guide.hello.HelloService/GetHello net::ERR_INVALID_HTTP_RESPONSE

This is expected, because we can’t call grpc on the browser, since grpc implement HTTP/2 protocol, whereas browser cannot control HTTP/2 request directly. This is a story for another article, but if you curious you can refer to this post. So, what is the solution? Here come the proxy part.

Proxy

We will run envoy proxy with docker. If you don’t have docker, download and install it first here.

  • add envoy-override.yaml. this will override the default envoy configuration.
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: backend_service
max_stream_duration:
grpc_timeout_header_max: 0s
cors:
allow_origin_string_match:
- prefix: "*"
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
max_age: "1728000"
expose_headers: custom-header-1,grpc-status,grpc-message
http_filters:
- name: envoy.filters.http.grpc_web
- name: envoy.filters.http.cors
- name: envoy.filters.http.router
clusters:
- name: backend_service
connect_timeout: 0.25s
type: logical_dns
http2_protocol_options: {}
lb_policy: round_robin
load_assignment:
cluster_name: cluster_0
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: host.docker.internal
port_value: 9090

Lets see the file. First, we add the admin interface and make it to listen at port 9901. Second, we create the listener. This is where we tell envoy to listen to any request that came from our client app (remember when I tell you this will be important?).

The next one we need to know is, we specifying http filter for our envoy proxy. In this case is grpc_web, cors and router.

After that we add the cluster. This is where we redirect the incoming request from our client (port 8080) to our backend service that listen to port 9090.

  • run the envoy proxy
docker run -p 8080:8080 -p 9901:9901 \
-v $(pwd)/envoy-override.yaml:/etc/envoy/envoy-override.yaml \
--rm envoyproxy/envoy-dev -c /etc/envoy/envoy-override.yaml \
--log-level debug

We tell docker to run envoy and specifying the port and the config file. The first port (8080) is the port that we want to listen and the second port (9901) is the port for envoy admin interface.

Test it

Now, if you manage to try the implementation yourself by follow the guide, congratulations and also thank you. If you starting all the server, client and proxy, then you can see the log in the browser:

bira
{message: 'Hello, bira'}

That’s it. You now ready to expand your new grpc skill to create anew api.

--

--

--

Engineer that create software. Never finished article.

Love podcasts or audiobooks? Learn on the go with our new app.

Recommended from Medium

A Change of Context

How to create stunning gradient buttons with mouse cursor tracking

How to Convert Audio from wav to mp3 in JavaScript Using ffmpeg.wasm

A First Glance @ Google’s API

Useful Features of TypeScript 4.4

Typescript is widely used scripting language nowadays which is used with Angular, ReactJs, VueJs and many other UI frameworks & Libraries. Typescript4.4 brought many most important features which could improve the code readability, maintainability etc.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Bhirawa Mbani

Bhirawa Mbani

Engineer that create software. Never finished article.

More from Medium

Using Minix with docker-compose

Enhance review process with automated tools and services in an open source application

Custom Checks In Github: A Probot Experience — Part 2

Authentication when using Google Cloud APIs from FileMaker

Authentication