@omkarkirpan/grpc-client

A gRPC client library that is nice to you

Usage no npm install needed!

<script type="module">
  import omkarkirpanGrpcClient from 'https://cdn.skypack.dev/@omkarkirpan/grpc-client';
</script>

README

@omkarkirpan/grpc-client npm version

A Node.js gRPC library that is nice to you. Built on top of grpc-js.

Features

  • 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.

Installation

npm install @omkarkirpan/grpc-client google-protobuf @grpc/grpc-js
npm install --save-dev @types/google-protobuf

Usage

Compiling Protobuf files

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.

Client

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();

Channels

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());

Metadata

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) {
    // ...
  },
});

Errors

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;
  }
}

Cancelling calls

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();

Deadlines

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;
  }
}

Server streaming

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(),
)) {
  // ...
}

Client streaming

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());

Middleware

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.

Example: Logging

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;
  }
}
Example: Timeouts

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',
});