npm package: @omkarkirpan/grpc-client
Description: A gRPC client library that is nice to you
Author: Omkar Kirpan
Latest version: 1.0.4

# @omkarkirpan/grpc-client [![npm version][npm-image]][npm-url]

A Node.js gRPC library that is nice to you. Built on top of
[`grpc-js`](https://www.npmjs.com/package/@grpc/grpc-js).

<!-- TOC depthFrom:2 depthTo:5 -->

- [Features](#features)
- [Installation](#installation)
- [Usage](#usage)
  - [Compiling Protobuf files](#compiling-protobuf-files)
  - [Client](#client)
    - [Channels](#channels)
    - [Metadata](#metadata-1)
    - [Errors](#errors-1)
    - [Cancelling calls](#cancelling-calls-1)
    - [Deadlines](#deadlines)
    - [Server streaming](#server-streaming-1)
    - [Client streaming](#client-streaming-1)
    - [Middleware](#middleware-1)
      - [Example: Logging](#example-logging-1)
      - [Example: Timeouts](#example-timeouts)

<!-- /TOC -->
<!-- Generated by Markdown TOC -->
<!-- https://marketplace.visualstudio.com/items?itemName=AlanWalk.markdown-toc -->

## Features

- Written in TypeScript for TypeScript.
- Modern API that uses Promises and Async Iterables for streaming.
- Cancelling client and server calls using
  [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/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](https://stackoverflow.com/questions/59447763/node-js-grpc-out-protoc-gen-grpc-1-is-not-a-valid-win32-application)

Alternative methods include [Buf](https://github.com/bufbuild/buf) and
[Prototool](https://github.com/uber/prototool).

### Client

Consider the following Protobuf definition:

```proto
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:

```ts
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](#example-timeouts).

Call the method:

```ts
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:

```ts
client.close();
```

#### Channels

By default, a channel uses insecure connection. The following are equivalent:

```ts
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:

```ts
createChannel('https://example.com:8080');
createChannel('example.com:8080', ChannelCredentials.createSsl());
```

#### Metadata

Client can send request metadata and receive response headers and trailers:

```ts
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.

```ts
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`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal).

```ts
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:

```ts
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:

```proto
service ExampleService {
  rpc ExampleStreamingMethod(ExampleRequest)
    returns (stream ExampleResponse) {};
}
```

Client method returns an Async Iterable:

```ts
for await (const response of client.exampleStreamingMethod(
  new ExampleRequest(),
)) {
  // ...
}
```

#### Client streaming

Given a client streaming method:

```proto
service ExampleService {
  rpc ExampleClientStreamingMethod(stream ExampleRequest)
    returns (ExampleResponse) {};
}
```

Client method expects an Async Iterable as its first argument:

```ts
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](#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:

```ts
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:

```ts
const clientFactory = createClientFactory().use(middleware);

const client1 = clientFactory.create(Service1, channel1);
const client2 = clientFactory.create(Service2, channel2);
```

You can also attach middleware per-client:

```ts
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:

```ts
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:

```ts
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:

```ts
const client = createClientFactory()
  .use(timeoutMiddleware)
  .create(ExampleService, channel, {
    '*': {
      timeout: '1m',
    },
    exampleUnaryMethod: {
      timeout: '30s',
    },
  });
```

Specify call options per-call:

```ts
await client.exampleUnaryMethod(new ExampleRequest(), {
  timeout: '15s',
});
```

[npm-image]: https://badge.fury.io/js/%40omkarkirpan%2Fgrpc-client.svg
[npm-url]: https://badge.fury.io/js/%40omkarkirpan%2Fgrpc-client