gRPC on Node.js with Buf and TypeScript (in 2024)

Slavo Vojacek
6 min readNov 5, 2020
gRPC/Buf + TypeScript + Node.js = ❤️

The article I wrote back in 2020 has been outdated for a while now, so I decided to give it a refresh 😎

I’m excited to share a simpler, up-to-date version based on all the cool stuff that’s been happening in the Buf ecosystem since the original publication.

Project setup

I will use the most basic Node + TypeScript configuration:

chatbot> npm init -y
chatbot> npm i -D typescript tsx @types/node
chatbot> npx tsc --init

Protobuf API

I always like to start by thinking through the interface and how my service will interact with the outside world.

chatbot> mkdir -p proto/chatbot/v1alpha1
chatbot> touch proto/chatbot/v1alpha1/service.proto

The ChatbotService will be a dummy implementation of an in-memory conversation between a user and a bot:

syntax = "proto3";

package chatbot.v1alpha1;

import "google/protobuf/timestamp.proto";

// This service provides a conversational interface for users.
service ChatbotService {
// Creates a new conversation.
rpc CreateConversation(CreateConversationRequest) returns (CreateConversationResponse) {}
// Retrieves the conversation with the given identifier.
rpc GetConversation(GetConversationRequest) returns (GetConversationResponse) {}
// Sends a message to a conversation.
rpc SendMessage(SendMessageRequest) returns (SendMessageResponse) {}
}

// The request message for the CreateConversation method.
message CreateConversationRequest {
// The user's email address. This can be used to send the summary of the conversation to the user.
string email = 1;
}

// The response message for the CreateConversation method.
message CreateConversationResponse {
// The conversation that was created.
Conversation conversation = 1;
}

// The request message for the GetConversation method.
message GetConversationRequest {
// The conversation to retrieve.
string conversation_id = 1;
}

// The response message for the GetConversation method.
message GetConversationResponse {
// The conversation that was retrieved.
Conversation conversation = 1;
}

// The request message for the SendMessage method.
message SendMessageRequest {
// The conversation to send a message to.
string conversation_id = 1;
// The message to send.
Message message = 2;
}

// The response message for the SendMessage method.
message SendMessageResponse {
// The message that was sent back to the user. This will always be a message from the bot.
Message message = 1;
}

// A conversation between the user and the bot.
message Conversation {
// The conversation's unique identifier.
string id = 1;
// The time at which the conversation was created.
google.protobuf.Timestamp create_time = 2;
// The user's email address. This can be used to send the summary of the conversation to the user.
string email = 3;
// The messages in this conversation in chronological order.
repeated Message messages = 4;
}

// A message in a conversation.
message Message {
// The message's unique identifier.
string id = 1;
// The time at which the message was sent.
google.protobuf.Timestamp create_time = 2;
// The author of the message, either the user or the bot.
Author author = 3;
// The content of the message.
string content = 4;
}

// The author of a message.
enum Author {
// Unspecified - should not be used.
AUTHOR_UNSPECIFIED = 0;
// The user.
AUTHOR_USER = 1;
// The bot.
AUTHOR_BOT = 2;
}

For reference, here’s my go-to buf.yaml config:

version: v1
breaking:
use:
- FILE
lint:
use:
- DEFAULT
- COMMENTS
- PACKAGE_NO_IMPORT_CYCLE

I always add Buf’s lint and breaking change checks into CI pipelines, although with varying levels of strictness depending on the project.

Code generation

Let’s proceed to code generation for TypeScript and Node.js. I will need to install Buf’s Node executable (@bufbuild/buf) as well as ES + Connect ES protoc compilers:

chatbot> npm install -D @bufbuild/buf \
@bufbuild/protoc-gen-es @connectrpc/protoc-gen-connect-es

I will use the following buf.gen.yaml to generate the outputs into gen:

version: v1
plugins:
- plugin: es
opt: target=ts
out: gen
- plugin: connect-es
opt: target=ts
out: gen

Time to generate some TypeScript code…

chatbot> npx buf generate proto

This is what the project looks like now (excluding node_modules):

chatbot> tree -I node_modules
├── buf.gen.yaml
├── buf.yaml
├── gen
│ └── chatbot
│ └── v1alpha1
│ ├── service_connect.ts
│ └── service_pb.ts
├── package-lock.json
├── package.json
├── proto
│ └── chatbot
│ └── v1alpha1
│ └── service.proto
└── tsconfig.json

Implementation and serving

The last step is to add the appropriate business logic to my handlers and serve the endpoints.

There’s a number of runtime dependencies I’ll need first:

chatbot> npm install @bufbuild/protobuf \
@connectrpc/connect @connectrpc/connect-node

I implemented a simple in-memory Conversations service in src/conversations/memory.ts:

import { randomUUID } from "crypto";

import { Conversation, Message } from "../domain/types";

export class MemoryConversationService {
private conversations: Map<string, Conversation> = new Map();

createConversation(email: string): Conversation {
const conversation: Conversation = {
id: randomUUID(),
createdAt: new Date(),
email: email,
messages: [],
};
this.conversations.set(conversation.id, conversation);

return conversation;
}

getConversation(conversationId: string): Conversation | undefined {
return this.conversations.get(conversationId);
}

sendMessage(conversationId: string, content: string): Message {
const conversation = this.conversations.get(conversationId);
if (!conversation) {
throw new Error("Conversation not found");
}

const userMessage: Message = {
id: randomUUID(),
createdAt: new Date(),
role: "user",
content: content,
};
conversation.messages.push(userMessage);

const botMessage: Message = {
id: randomUUID(),
createdAt: new Date(),
role: "bot",
content: "Hello from Connect!",
};
conversation.messages.push(botMessage);

return botMessage;
}
}

I like to put all domain definitions in a separate module as this allows for re-use across different parts of the codebase. In src/domain/types.ts:

export interface Message {
id: string;
createdAt: Date;
role: "user" | "bot";
content: string;
}

export interface Conversation {
id: string;
createdAt: Date;
email: string;
messages: Message[];
}

Let’s implement the Connect handlers that will use an implementation of the Conversation service in src/connect.ts:

import { Code, ConnectError, ConnectRouter } from "@connectrpc/connect";

import { ChatbotService } from "../gen/chatbot/v1alpha1/service_connect";

import type { Conversation, Message } from "./domain/types";
import * as convert from "./connect.convert";

// This is the interface that the ConnectRouter expects to be implemented
interface ConversationsService {
createConversation(email: string): Conversation;
getConversation(conversationId: string): Conversation | undefined;
sendMessage(conversationId: string, content: string): Message;
}

export default (conversationsService: ConversationsService) =>
(router: ConnectRouter) =>
// Registers chatbot.v1alpha1.ChatbotService and exposes its methods on the ConnectRouter
router.service(ChatbotService, {
async createConversation(req) {
const conversation = conversationsService.createConversation(req.email);

return {
conversation: convert.convertConversationToPb(conversation),
};
},
async getConversation(req) {
const conversation = conversationsService.getConversation(
req.conversationId,
);

if (!conversation) {
throw new ConnectError("Conversation not found", Code.NotFound);
}

return {
conversation: convert.convertConversationToPb(conversation),
};
},
async sendMessage(req) {
if (!req.message) {
throw new ConnectError("Message is required", Code.InvalidArgument);
}

const message = conversationsService.sendMessage(
req.conversationId,
req.message.content,
);

return {
message: convert.convertMessageToPb(message),
};
},
});

For those wondering why I didn’t just import the MemoryConversationService directly, I am trying to keep the router de-coupled from any specific implementation of the Conversation service (which is a simple way of saying I am trying to adhere to the Dependency Inversion principle).

For completeness, here are the converters:

import { Timestamp } from "@bufbuild/protobuf";

import * as pb from "../gen/chatbot/v1alpha1/service_pb";

import type { Conversation, Message } from "./domain/types";

export const convertConversationToPb = (
conversation: Conversation,
): pb.Conversation => {
return new pb.Conversation({
id: conversation.id,
createTime: Timestamp.fromDate(conversation.createdAt),
email: conversation.email,
messages: conversation.messages.map(convertMessageToPb),
});
};

export const convertMessageToPb = (message: Message): pb.Message => {
return new pb.Message({
id: message.id,
createTime: Timestamp.fromDate(message.createdAt),
author: convertRoleToAuthor(message.role),
content: message.content,
});
};

export const convertRoleToAuthor = (role: string): pb.Author => {
switch (role) {
case "user":
return pb.Author.USER;
case "bot":
return pb.Author.BOT;
default:
return pb.Author.UNSPECIFIED;
}
};

For serving, I will use Fastify:

chatbot> npm install fastify @connectrpc/connect-fastify

Now I have everything I need to run an HTTP server.ts:

import { fastify } from "fastify";
import { fastifyConnectPlugin } from "@connectrpc/connect-fastify";

import createRoutes from "./src/connect";
import { MemoryConversationService } from "./src/conversations/memory";

const conversationService = new MemoryConversationService();

async function main() {
const server = fastify();

await server.register(fastifyConnectPlugin, {
routes: createRoutes(conversationService),
});

server.get("/health", (_, reply) => {
reply.type("text/plain");
reply.send("OK");
});

await server.listen({ host: "localhost", port: 8080 });

console.log("server is listening at", server.addresses());
}

void main();

Running the server…

chatbot> npx tsx server.ts

Testing

Create a new conversation:

chatbot> curl \
--header 'Content-Type: application/json' \
--data '{"email": "james@bond.com"}' \
http://localhost:8080/chatbot.v1alpha1.ChatbotService/CreateConversation
{"conversation":{"id":"c72223df-108c-418b-a3d3-610fa42d2611","createTime":"2023-09-30T15:10:35.769Z","email":"james@bond.com"}}%

Get an existing conversation:

chatbot> curl \
--header 'Content-Type: application/json' \
--data '{"conversation_id": "c72223df-108c-418b-a3d3-610fa42d2611"}' \
http://localhost:8080/chatbot.v1alpha1.ChatbotService/GetConversation
{"conversation":{"id":"c72223df-108c-418b-a3d3-610fa42d2611","createTime":"2023-09-30T15:10:35.769Z","email":"james@bond.com"}}%

Send a message:

chatbot> curl \
--header 'Content-Type: application/json' \
--data '{"conversation_id": "c72223df-108c-418b-a3d3-610fa42d2611", "message": {"content": "My name is Bond... James Bond."}}' \
http://localhost:8080/chatbot.v1alpha1.ChatbotService/SendMessage
{"message":{"id":"987abde4-67a5-4e07-a2d1-b7422eaf544a","createTime":"2023-09-30T15:13:01.992Z","author":"AUTHOR_BOT","content":"Hello from Connect!"}}%

Now, I am able to see the messages in a conversation:

chatbot> curl \
--header 'Content-Type: application/json' \
--data '{"conversation_id": "c72223df-108c-418b-a3d3-610fa42d2611"}' \
http://localhost:8080/chatbot.v1alpha1.ChatbotService/GetConversation
{"conversation":{"id":"c72223df-108c-418b-a3d3-610fa42d2611","createTime":"2023-09-30T15:10:35.769Z","email":"james@bond.com","messages":[{"id":"323b049b-9193-42b3-98aa-f0aeac530b67","createTime":"2023-09-30T15:13:01.992Z","author":"AUTHOR_USER","content":"My name is Bond... James Bond."},{"id":"987abde4-67a5-4e07-a2d1-b7422eaf544a","createTime":"2023-09-30T15:13:01.992Z","author":"AUTHOR_BOT","content":"Hello from Connect!"}]}}%

Hope you enjoyed this!

References:

--

--

Slavo Vojacek

I am passionate about building cool things with world-class teams and technology.