Complete guide creating APIs using grpc-js and grpc-web with typescript
We will create both grpc server and client and also setup the proxy (using envoy).
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
- grpc-web
- next
- protoc-gen-grpc-web. download here.
- protoc. download here.
- google-protobuf
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.