<

Trying out connect-web

An article about connect-web was trending on Hatena Bookmark. I was curious, so I tried it out.

The sample code is placed in the following repository.

Preface: A rough understanding of gRPC and connect-web

gRPC is a protocol for implementing RPC (Remote Procedure Call). This protocol cannot be used(?) from the browser side, so you will need to use something called gRPC-Web, which is a browser-oriented gRPC. In that case, it seems necessary to set up a proxy between the browser and the server. (Probably)

Therefore, a set of libraries called Connect was developed to build an HTTP API compatible with gRPC. Thanks to this, it is possible to use gRPC from the browser side without the need to set up a proxy.

The above page has items that the backend is connect-go and the frontend is connect-web. connect-web is a small library for running RPC from the browser. It's a type-safe library, so type completion works. connect-go allows you to create a Connect service in go.

Therefore, the development of the frontend will use connect-web. From here on, I will introduce the work of the frontend. By the way, I will use React.

What I tried

The frontend side mainly consists of the following two tasks.

  1. Generate TypeScript files from Protocol Buffer schema
  2. Implement gRPC client from generated TypeScript files

1. Generate TypeScript files from Protocol Buffer schema

You need a schema, a ProtocolBuffer schema, to communicate with gRPC. I will use what is already available.

Specifically, it is a schema like the following.

syntax = "proto3";

service ElizaService {
  rpc Say(SayRequest) returns (SayResponse) {}
}

message SayRequest {
  string sentence = 1;
}

message SayResponse {
  string sentence = 1;
}

To generate TypeScript code, use the CLI called buf. Write the following definition file to use with buf.

# buf.gen.yaml

# buf.gen.yaml defines a local generation template.
# For details, see https://docs.buf.build/configuration/v1/buf-gen-yaml
version: v1
plugins:
  - name: es
    path: node_modules/.bin/protoc-gen-es
    out: gen
    # With target=ts, we generate TypeScript files.
    # Use target=js+dts to generate JavaScript and TypeScript declaration files
    # like remote generation does.
    opt: target=ts
  - name: connect-web
    path: node_modules/.bin/protoc-gen-connect-web
    out: gen
    # With target=ts, we generate TypeScript files.
    opt: target=ts

This is configuration information on what kind of output to produce when you do buf generate mentioned later. I think it's something like a yaml file for codegen. To run this, let's install the following module.

# plugin
yarn add --dev @bufbuild/protoc-gen-connect-web @bufbuild/protoc-gen-es
# runtime
yarn add @bufbuild/connect-web @bufbuild/protobuf
  • plugin
    • protoc-gen-es
      • Generate basic types such as request and response messages
    • protoc-gen-connect-web
      • Generate services from Protocol Buffer schema
  • runtime
    • bufbuild/connect-web
      • Provides clients for Connect and gRPC-web protocols
    • bufbuild/protobuf
      • Provides serialization for basic types

Next, let's install buf. I installed it with brew.

brew install bufbuild/buf/buf
# ref: https://github.com/bufbuild/buf#installation

Let's generate TypeScript files from the ProtocolBuffer schema.

buf generate --template buf.gen.yaml buf.build/bufbuild/eliza

Upon success, the following two TypeScript files will be generated:

  • gen/buf/connect/demo/eliza/v1/eliza_connectweb.ts
  • gen/buf/connect/demo/eliza/v1/eliza_pb.ts

eliza_connectweb.ts includes the following code:

// eliza_connectweb.ts
import { SayRequest, SayResponse } from "./eliza_pb.js";
import { MethodKind } from "@bufbuild/protobuf";

export const ElizaService = {
  typeName: "ElizaService",
  methods: {
    say: {
      name: "Say",
      I: SayRequest,
      O: SayResponse,
      kind: MethodKind.Unary,
    },
  },
} as const;

eliza_pb.ts includes the following code:

export class SayRequest extends Message<SayRequest> {
  /**
   * @generated from field: string sentence = 1;
   */
  sentence = "";

  constructor(data?: PartialMessage<SayRequest>) {
    super();
    proto3.util.initPartial(data, this);
  }

  static readonly runtime = proto3;
  static readonly typeName = "buf.connect.demo.eliza.v1.SayRequest";
  # ... 省略 ...
}

/**
 * SayResponse describes the sentence responded by the ELIZA program.
 *
 * @generated from message buf.connect.demo.eliza.v1.SayResponse
 */
export class SayResponse extends Message<SayResponse> {
  /**
   * @generated from field: string sentence = 1;
   */
  sentence = "";

  constructor(data?: PartialMessage<SayResponse>) {
    super();
    proto3.util.initPartial(data, this);
  }

  static readonly runtime = proto3;
  static readonly typeName = "buf.connect.demo.eliza.v1.SayResponse";
  # ... 省略 ...
}

With this, the preparation is complete.

2. Implementing a gRPC client from the generated TypeScript files

Let's implement a gRPC client. A gRPC client can be generated with createPromiseClient. You need to pass a service and transport(?) as arguments when generating. It's easier to understand by looking at the code, so please look at the following code:

// client.ts
import { useMemo } from "react";
import { ServiceType } from "@bufbuild/protobuf";
import {
  createConnectTransport,
  createPromiseClient,
  PromiseClient,
  Transport,
} from "@bufbuild/connect-web";

const transport = createConnectTransport({
  baseUrl: "https://demo.connect.build", # バックエンド側のURL
});

export function useClient<T extends ServiceType>(service: T): PromiseClient<T> {
  return useMemo(() => createPromiseClient(service, transport), [service]);
}

Let's try using this client.

// App.tsx

import { createConnectTransport, Interceptor } from "@bufbuild/connect-web";
import { ElizaService } from "../gen/buf/connect/demo/eliza/v1/eliza_connectweb";
import { useClient } from "./client";

function App() {
  const client = useClient(ElizaService);
  client
    .say({
      sentence: "hello",
    })
    .then(({ sentence }) => {
      console.log(sentence);
    });
  // ...
}

In this way, ProtocolBuffers' ElizaService becomes available for type completion. It feels good!

In conclusion

I was surprised that it worked surprisingly smoothly.

If it was helpful, support me with a ☕!

Share

Related tags