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 --initProtobuf 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.protoThe 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_CYCLEI 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-esI 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: genTime to generate some TypeScript codeβ¦
chatbot> npx buf generate protoThis 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.jsonImplementation 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-nodeI 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-fastifyNow 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.tsTesting
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: