DEV Community

Yuta Kusuno
Yuta Kusuno

Posted on

[Hono] Simple Messaging App using Bun and WebSocket

We often see implementations of WebSocket using the Express framework and Socket.io. However, there seem to be fewer examples of WebSocket implementations using Hono, a framework that is similar to Express but faster and lighter. In this article, I will introduce the implementation of a simple messaging app using Hono and Bun, a JavaScript runtime.

This project has a simple structure, and it is possible to extend various features such as database utilization and multiple room management functions. Initially, I aimed to write an article focused on WebSocket that could be read in less than 3 minutes, but I was drawn in by the charm of Hono, and the volume of the article increased more than expected. Now, let’s move on to the details.

Tech Stack

Frontend:

Backend:

JavaScript Runtime(Both Frontend and Backend):

Project Structure

├── frontend
│   ├── src
│   │   ├── App.css
│   │   ├── App.tsx
│   │   ├── index.css
│   │   ├── main.tsx
│   │   └── vite-env.d.ts
│   ├── bun.lockb
│   ├── index.html
│   ├── package.json
│   ├── tsconfig.json
│   ├── tsconfig.node.json
│   └── vite.config.ts
├── server
│   └── index.ts
├── shared
│   ├── constants.ts
│   └── types.ts
├── bun.lockb
├── package.json
└── tsconfig.json
Enter fullscreen mode Exit fullscreen mode
  • Root directory and server directory: Hono app
  • Frontend directory: React app
  • Shared directory: Constants and types used in common between frontend and backend

I have omitted settings files for TailwindCSS, etc. The structure is such that the Hono app wraps the React app. This was an easy-to-manage configuration when implementing RPC (Remote Procedure Call), where the frontend and backend share dependencies and type definitions of Hono.

Repository:
https://github.com/yutakusuno/bun-hono-react-websocket

UI

app demo

What is Hono?

Hono is a web application framework that is extremely fast and lightweight. It operates on any JavaScript runtime and includes built-in middleware and helpers. Its implementation is similar to Express, making it intuitive to use. It also features a clean API and first-class support for TypeScript.

For more details, visit: https://hono.dev/

What is Bun?

Bun is a JavaScript runtime and an all-in-one toolkit for JavaScript and TypeScript applications. It is written in Zig and internally uses JavaScriptCore, a performance-oriented JS engine created for Safari. It also implements Node.js and Web API natively and provides all the tools necessary to build JavaScript applications, including a package manager, test runner, and bundler.

For more details, visit: https://bun.sh/

What is WebSocket?

WebSocket is a protocol that creates a persistent bidirectional communication channel between a web browser and a server. With this technology, web applications can exchange data with the server in real-time without the client initiating a new HTTP request or reloading the page.

The operation of WebSocket goes through the following process:

Starting the handshake: The client sends the following HTTP request to the server, requesting an upgrade from HTTP to WebSocket.

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Enter fullscreen mode Exit fullscreen mode

Server response: If the server supports WebSocket and agrees to the upgrade, the server responds with its own handshake, confirming the switch to the WebSocket protocol.

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
Enter fullscreen mode Exit fullscreen mode

Data transmission: While maintaining an open connection, the client and server can exchange data.

WebSocket Diagram

This diagram shows the basic flow of WebSocket communication between the client and server. First, the client requests an upgrade to WebSocket from the server via an HTTP request. Then, the server returns an HTTP response, confirming the protocol switch. This opens the connection, and the client and server can exchange data.

I would like to delve deeper into this area to understand it better, but that’s it for now.

The request and response examples for the handshake are quoted from the RFC.
https://datatracker.ietf.org/doc/html/rfc6455#section-1.2

Implementation

I will focus on key implementations by extracting parts from this repository. I want to explain as much as possible, including the import of dependencies and the definition of variables, so there are parts where the actual implementation has been rewritten as needed. If you want to understand in detail, please read the source code of the repository in parallel, treating this article as supplementary.

You can check out the completed application here: https://github.com/yutakusuno/bun-hono-react-websocket

Backend: WebSocket Configuration

server/index.ts

import { Hono } from 'hono';
import { createBunWebSocket } from 'hono/bun';

const app = new Hono();
const { upgradeWebSocket, websocket } = createBunWebSocket();
const server = Bun.serve({
  fetch: app.fetch,
  port: 3000,
  websocket,
});

export default app;
Enter fullscreen mode Exit fullscreen mode

When launching an HTTP server with Bun, it is recommended to use Bun.serve. Pass an instance of the Hono class to this fetch handler and specify the port of the backend server. The websocket imported from createBunWebSocket is a Hono middleware, WebSocket handler, implemented for Bun.

Backend: WebSocket Connection and Disconnection

server/index.ts

import type { ServerWebSocket } from 'bun';

const topic = 'anonymous-chat-room';

app.get(
  '/ws',
  upgradeWebSocket((_) => ({
    onOpen(_, ws) {
      const rawWs = ws.raw as ServerWebSocket;
      rawWs.subscribe(topic);
      console.log(`WebSocket server opened and subscribed to topic '${topic}'`);
    },
    onClose(_, ws) {
      const rawWs = ws.raw as ServerWebSocket;
      rawWs.unsubscribe(topic);
      console.log(
        `WebSocket server closed and unsubscribed from topic '${topic}'`
      );
    },
  }))
);

Enter fullscreen mode Exit fullscreen mode

The behavior when a WebSocket connection is opened and closed is defined. When a connection is opened, it starts subscribing to a specific topic synonymous-chat-room, and when a connection is closed, it ends that subscription. In this app, when an anonymous user opens a page, it establishes a WebSocket communication and starts subscribing to the topic. This time, there is no limit to the number of people who can subscribe to the same topic.

Subscription to topics is not directly supported in Hono’s implementation, so we need to use Bun’s ServerWebSocket. Specifically, this is achieved by extending the ws in onOpen. Looking at the source code of Hono, ws.raw is the WebSocket of the Bun server, and by using this, we can handle Bun native WebSocket and implement topic subscription. Pass any topic name as a string to subscribe and unsubscribe.

Hono’s implementation of Bun WebSocket:
https://github.com/honojs/hono/blob/main/src/adapter/bun/websocket.ts

Official documentation for Bun WebSocket:
https://bun.sh/docs/api/websockets

Backend: /messages Endpoint

server/index.ts

import { zValidator } from '@hono/zod-validator';
const messages: Message[] = [];

const messagesRoute = app
  .get('/messages', (c) => {
    return c.json(messages);
  })
  .post(
    '/messages',
    zValidator('form', MessageFormSchema, (result, c) => {
      if (!result.success) {
        return c.json({ ok: false }, 400);
      }
    }),
    async (c) => {
      const param = c.req.valid('form');
      const currentDateTime = new Date();
      const message: Message = {
        id: Number(currentDateTime),
        date: currentDateTime.toLocaleString(),
        ...param,
      };
      const data: DataToSend = {
        action: publishActions.UPDATE_CHAT,
        message: message,
      };

      messages.push(message);
      server.publish(topic, JSON.stringify(data));

      return c.json({ ok: true });
    }
  )
  .delete('/messages/:id', (c) => {
    // Logic of message deletion
  });

export type AppType = typeof messagesRoute;
Enter fullscreen mode Exit fullscreen mode

This messages resource supports GET, POST, DELETE methods. The GET method retrieves the message history of users subscribing to the same topic, the POST method creates a new message, and the DELETE method deletes a specific message. Here, I will focus on explaining POST /messages.

The server instance was created when creating the Bun server for use in this endpoint. By calling publish() on the server instance, we can broadcast to all clients subscribing to the same topic. According to the official document, it is also possible to broadcast to all subscribers of the topic, excluding the socket that is called publish(), but it is not used this time.

Also, like Express, we can implement middleware on the endpoint by passing a handler before processing the logic of the resource. This is part of zValidator. This allows us to handle param in a type-safe manner.

In type AppType, the type of API defined in messagesRoute is exported. This is utilized in the RPC implementation on the frontend described later. By sharing the API specification with the client, we can achieve a type-safe API implementation.

shared/types.ts

import { z } from 'zod';

export const MessageFormSchema = z.object({
  userId: z.string().min(1),
  text: z.string().trim().min(1),
});
Enter fullscreen mode Exit fullscreen mode

I used zod for the MessageFormSchema. By combining TypeScript and zod, we can implement more strict validation.

Frontend: Setting up WebSocket

frontend/src/App.tsx

const [messages, setMessages] = useState<Message[]>([]);

useEffect(() => {
  const socket = new WebSocket('ws://localhost:3000/ws');

  socket.onopen = (event) => {
    console.log('WebSocket client opened', event);
  };

  socket.onmessage = (event) => {
    try {
      const data: DataToSend = JSON.parse(event.data.toString());
      switch (data.action) {
        case publishActions.UPDATE_CHAT:
          setMessages((prev) => [...prev, data.message]);
          break;
        case publishActions.DELETE_CHAT:
          setMessages((prev) =>
            prev.filter((message) => message.id !== data.message.id)
          );
          break;
        default:
          console.error('Unknown data:', data);
      }
    } catch (_) {
      console.log('Message from server:', event.data);
    }
  };
  socket.onclose = (event) => {
    console.log('WebSocket client closed', event);
  };

  return () => {
    socket.close();
  };
}, []);

Enter fullscreen mode Exit fullscreen mode

I create a WebSocket client and connect it to the server. I define the behavior for when the WebSocket connection is opened, when a message is received, and when the connection is closed. In socket.onmessage, I use a switch statement to branch based on the action type received from the backend. This allows us to handle various use cases.

// shared/constants.ts
export const publishActions = {
  UPDATE_CHAT: 'UPDATE_CHAT',
  DELETE_CHAT: 'DELETE_CHAT',
} as const;

// shared/types.ts
type PublishAction = (typeof publishActions)[keyof typeof publishActions];

export type Message = { id: number; date: string } & MessageFormValues;

export type DataToSend = {
  action: PublishAction;
  message: Message;
};
Enter fullscreen mode Exit fullscreen mode

Here are the constants and type definitions used for WebSocket and message management. The PublishAction type infers the sum of the values of the publishActions object and extracts the enumeration type.

Frontend: Sending Messages

frontend/src/App.tsx

import { hc } from 'hono/client';
import type { AppType } from '@server/index';

const honoClient = hc<AppType>("http://localhost:3000");

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
  e.preventDefault();
  try {
    const validatedValues = MessageFormSchema.parse(formValues);
    const response = await honoClient.messages.$post({
      form: validatedValues,
    });
    if (!response.ok) {
      throw new Error('Failed to send message');
    }

  } catch (error) {
    // Error Handling Logic
  }
};

Enter fullscreen mode Exit fullscreen mode

In handleSubmit, I define the logic for sending a new message. When text is sent, it validates the input value and sends the new message as a POST request to the server.

What’s noteworthy is that I use the Hono client instead of the fetch API. I specify the AppType exported in the backend and the backend URL to hc, and define a type-safe honoClient. This enables the implementation of RPC. The gif below is a demo of TypeScript throwing a compile error when an incorrect data type value is passed to $post.

RPC Gif

This allows us to implement a type-safe API by combining zod and RPC.

<form
  method="post"
  onSubmit={handleSubmit}
  className="flex items-center space-x-2"
>
  <input name="userId" defaultValue={formValues.userId} hidden />
  <input
    name="text"
    value={formValues.text}
    onChange={handleInputChange}
    className="flex-grow p-2 border border-gray-800 rounded-md bg-gray-800 text-white"
  />
  <button
    type="submit"
    className="px-4 py-2 bg-blue-500 text-white rounded-md"
  >
    Send
  </button>
</form>
Enter fullscreen mode Exit fullscreen mode

The UI implementation is excerpted only for the message-sending form part. This configuration is simple, placing a message input tag and a message send button inside the form tag. When the send button is pressed, handleSubmit is triggered, and a request is sent to the backend.

That’s the introduction to the main implementation. Personally, I found that setting up WebSocket with Hono and Bun was not difficult, and I felt the difficulty level was equivalent to implementing WebSocket with Socket.io in Express. I was planning to write a post on WebSocket, but the implementation of a type-safe API using RPC with Hono also provided a very good development experience, so the post became long. I’m looking forward to future updates of Hono.

That is about it. Happy coding!

Top comments (0)