A Node.js gRPC library that is nice to you. Built on top of
grpc-js
.
- Written in TypeScript for TypeScript.
- Modern API that uses Promises and Async Iterables for streaming.
- Cancelling client and server calls using
AbortSignal
. - Client and server middleware support via concise API that uses Async Generators.
npm install @omkarkirpan/grpc-client google-protobuf @grpc/grpc-js
npm install --save-dev @types/google-protobuf
This works the same way as you would do for grpc-js
.
Install necessary tools:
npm install --save-dev grpc-tools grpc_tools_node_protoc_ts
Given a Protobuf file ./proto/example.proto
, generate JS code and TypeScript
definitions into directory ./compiled_proto
:
./node_modules/.bin/grpc_tools_node_protoc \
--plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts \
--plugin=protoc-gen-grpc=./node_modules/.bin/grpc_tools_node_protoc_plugin \
--js_out=import_style=commonjs,binary:./compiled_proto \
--ts_out=grpc_js:./compiled_proto \
--grpc_out=grpc_js:./compiled_proto \
./proto/example.proto
for windows, if you are facing issue genrating compiled proto files check this workaround https://stackoverflow.com/questions/59447763/node-js-grpc-out-protoc-gen-grpc-1-is-not-a-valid-win32-application
Alternative methods include Buf and Prototool.
Consider the following Protobuf definition:
syntax = "proto3";
package pkg.example;
service ExampleService {
rpc ExampleUnaryMethod(ExampleRequest) returns (ExampleResponse) {};
}
message ExampleRequest {
// ...
}
message ExampleResponse {
// ...
}
After compiling Protobuf file, we can create the client:
import {createChannel, createClient} from '@omkarkirpan/grpc-client';
import {ExampleService} from './compiled_proto/example_grpc_pb';
const channel = createChannel('localhost:8080');
const client = createClient(ExampleService, channel);
When creating a client, you can specify default call options for all methods, or per-method. See Example: Timeouts.
Call the method:
import {ExampleRequest, ExampleResponse} from './compiled_proto/example_pb';
const response: ExampleResponse = await client.exampleUnaryMethod(
new ExampleRequest(),
);
Once we've done with the client, close the channel:
client.close();
By default, a channel uses insecure connection. The following are equivalent:
import {ChannelCredentials} from '@grpc/grpc-js';
import {createChannel} from '@omkarkirpan/grpc-client';
createChannel('example.com:8080');
createChannel('http://example.com:8080');
createChannel('example.com:8080', ChannelCredentials.createInsecure());
To connect over TLS, use one of the following:
createChannel('https://example.com:8080');
createChannel('example.com:8080', ChannelCredentials.createSsl());
Client can send request metadata and receive response headers and trailers:
import {Metadata} from '@grpc/grpc-js';
const metadata = new Metadata();
metadata.set('key', 'value');
const response = await client.exampleUnaryMethod(new ExampleRequest(), {
metadata,
onHeader(header: Metadata) {
// ...
},
onTrailer(trailer: Metadata) {
// ...
},
});
Client calls may throw gRPC errors represented as ClientError
, that contain
status code and description.
import {status} from '@grpc/grpc-js';
import {ClientError} from '@omkarkirpan/grpc-client';
let response: ExampleResponse | null;
try {
response = await client.exampleUnaryMethod(new ExampleRequest());
} catch (error: unknown) {
if (error instanceof ClientError && error.code === status.NOT_FOUND) {
response = null;
} else {
throw error;
}
}
A client call can be cancelled using
AbortSignal
.
import AbortController from 'node-abort-controller';
import {isAbortError} from '@omkarkirpan/abort-controller-x';
const abortController = new AbortController();
client
.exampleUnaryMethod(new ExampleRequest(), {
signal: abortController.signal,
})
.catch(error => {
if (isAbortError(error)) {
// aborted
} else {
throw error;
}
});
abortController.abort();
You can specify a deadline for a client call using Date
object:
import {status} from '@grpc/grpc-js';
import {ClientError} from '@omkarkirpan/grpc-client';
import {addSeconds} from 'date-fns';
try {
const response = await client.exampleUnaryMethod(new ExampleRequest(), {
deadline: addSeconds(new Date(), 15),
});
} catch (error: unknown) {
if (error instanceof ClientError && error.code === status.DEADLINE_EXCEEDED) {
// timed out
} else {
throw error;
}
}
Consider the following Protobuf definition:
service ExampleService {
rpc ExampleStreamingMethod(ExampleRequest)
returns (stream ExampleResponse) {};
}
Client method returns an Async Iterable:
for await (const response of client.exampleStreamingMethod(
new ExampleRequest(),
)) {
// ...
}
Given a client streaming method:
service ExampleService {
rpc ExampleClientStreamingMethod(stream ExampleRequest)
returns (ExampleResponse) {};
}
Client method expects an Async Iterable as its first argument:
async function* createRequest(): AsyncIterable<ExampleRequest> {
for (let i = 0; i < 10; i++) {
yield new ExampleRequest();
}
}
const response = await client.exampleClientStreamingMethod(createRequest());
Client middleware intercepts outgoing calls allowing to:
- Execute any logic before and after reaching server
- Modify request metadata
- Look into request, response and response metadata
- Send call multiple times for retries or hedging
- Augment call options type to have own configuration
Client middleware is defined as an Async Generator and is very similar to Server middleware. Key differences:
- Middleware invocation order is reversed: middleware that is attached first, will be invoked last.
- There's no such thing as
CallContext
for client middleware; instead,CallOptions
are passed through the chain and can be accessed or altered by a middleware.
To create a client with middleware, use a client factory:
import {createClientFactory} from '@omkarkirpan/grpc-client';
const client = createClientFactory()
.use(middleware1)
.use(middleware2)
.create(ExampleService, channel);
A middleware that is attached first, will be invoked last.
You can reuse a single factory to create multiple clients:
const clientFactory = createClientFactory().use(middleware);
const client1 = clientFactory.create(Service1, channel1);
const client2 = clientFactory.create(Service2, channel2);
You can also attach middleware per-client:
const factory = createClientFactory().use(middlewareA);
const client1 = clientFactory.use(middlewareB).create(Service1, channel1);
const client2 = clientFactory.use(middlewareC).create(Service2, channel2);
In the above example, Service1
client gets middlewareA
and middlewareB
,
and Service2
client gets middlewareA
and middlewareC
.
Log all calls:
import {
ClientMiddlewareCall,
CallOptions,
ClientError,
} from '@omkarkirpan/grpc-client';
import {isAbortError} from '@omkarkirpan/abort-controller-x';
async function* loggingMiddleware<Request, Response>(
call: ClientMiddlewareCall<Request, Response>,
options: CallOptions,
) {
const {path} = call.definition;
console.log('Client call', path, 'start');
try {
const result = yield* call.next(call.request, options);
console.log('Client call', path, 'end: OK');
return result;
} catch (error) {
if (error instanceof ClientError) {
console.log('Client call', path, `end: ${status[error.code]}`);
} else if (isAbortError(error)) {
console.log('Client call', path, 'cancel');
} else {
console.log('Client call', path, `error: ${error?.stack}`);
}
throw error;
}
}
Add support for specifying timeouts for unary calls instead of absolute deadlines:
import ms = require('ms');
import {ClientMiddlewareCall, CallOptions} from '@omkarkirpan/grpc-client';
type TimeoutCallOptionsExt = {
/**
* Examples: '10s', '1m'
*/
timeout?: string;
};
async function* timeoutMiddleware<Request, Response>(
call: ClientMiddlewareCall<Request, Response>,
options: CallOptions & TimeoutCallOptionsExt,
) {
const {timeout, ...nextOptions} = options;
if (timeout != null && !call.requestStream && !call.responseStream) {
nextOptions.deadline ??= new Date(Date.now() + ms(timeout));
}
return yield* call.next(call.request, nextOptions);
}
When creating a client, you can specify default call options for all methods, or per-method:
const client = createClientFactory()
.use(timeoutMiddleware)
.create(ExampleService, channel, {
'*': {
timeout: '1m',
},
exampleUnaryMethod: {
timeout: '30s',
},
});
Specify call options per-call:
await client.exampleUnaryMethod(new ExampleRequest(), {
timeout: '15s',
});