I Created a Web App with hono Where Everyone Can Knead Clay Together
-
Posted on June 27, 2024
Introduction
You want to create an app where you can see everyone's reactions in real-time, right?
On various social media platforms like X and Instagram, a number appears on notifications when there's some action.
It's the same with Slack and Chatwork. When you're mentioned, a number appears on the channel.
Monster Hunter is incredible.
Up to four players hunt the same monster, and you can check where everyone is in real-time.
When you're being chased by Diablos, running away half in tears, and you see that a player who's supposed to be fighting alongside you hasn't moved from the base or is clearly busy gathering materials, you feel frustrated.
To implement this kind of real-time communication, I found several methods:
- WebSocket
- WebRTC
- SSE (Server-Sent Events)
I Want to Try WebSocket
I actually wanted to try WebRTC, but I gave up because I couldn't set up my own STUN server.
If I get the chance, I'd like to try WebRTC SFU Sora provided by Shiguredo.
I looked at the SSE sample from hono, but I got the impression that it can receive data from the server when some kind of stream is open? I couldn't imagine using SSE for requirements where the start timing couldn't be unified, so I gave up.
So, through a process of passive elimination, I decided to use WebSocket.
What Kind of App Did I Create?
From the names Cloudflare and hono, I wanted to create something with a "fire"-related name and came up with an app called VOYA.
I was thinking of an app where everyone sets fire to houses, and you could react with "Nice VOYA" to places that caught fire.
While playing with shaders, I accidentally created something that looked like kneading clay, so I thought it might be interesting if everyone could knead clay together.
Thinking about it now, I realize "it's a one-trick pony and in poor taste."
I'm glad I didn't go through with it...
https://github.com/hideyuki-hori/clayish
I named it "Clayish" because it's clay-like.
I was struggling with the color scheme, but when I consulted with my colleague, designer Minami, she suggested a very cute color scheme.
When I had my colleagues play with it, they said it looks like the profile of Murasaki Shikibu.
That's quite refined~~~~~
Using Cloudflare
Cloudflare has been catching my eye a lot recently.
Cloudflare is a Cloud Function with R2 (storage), D1 (DB), and KV.
They also have Queue and AI features.
And above all, it's cheap!
You can use it for $5 with the Workers paid plan.
With services like EC2 and ECS, if you have some server knowledge, you can create something that works for the time being.
However, to design according to the cloud vendor's methods, you need advanced knowledge to estimate access numbers, calculate server specs, and select options.
There's also a lot of work outside the domain, like server updates.
Even with auto-scaling, you need to carefully calculate based on load test results to set thresholds and configure it to withstand momentary access spikes.
While we don't need to consider all of this for this app, I found these points about Cloudflare Workers attractive:
- Automatic global deployment to hundreds of data centers
- Maintenance-free infrastructure with automatic scaling
- High-performance runtime without cold starts
Using hono
I love how it's designed to use native features as much as possible.
For example, different frameworks have their own unique properties for Requests, and I often find myself thinking, "Please just let me write it normally."
While I "like new things but don't want to introduce newness to invariant things," I found many aspects of hono's architecture that I could relate to, and I felt very happy while working with it.
Also, by combining it with vite, jsx works on both server-side and client-side, and there are React-like hooks, so you can create interactive apps.
It also has the necessary features to make it as SSG as possible, and I think by combining it with KV, which stores data collected at certain times like aggregate data, you can design with reduced costs.
There are four menu buttons at the bottom.
From left to right:
- Rotate the object
- Pull the clay
- Push the clay
- Change color and brush size
Among these, whenever these events occur:
- Pull the clay
- Push the clay
They are broadcast via WebSocket and the same operation is reflected on the clay opened by other people.
About WebSocket Implementation
To hold the WebSocket instance across requests, we use DurableObjects.
JavaScript objects can be shared as is, so it's very easy to use, but there's a bit of a trick to the implementation.
1. Configure wrangler.toml
The implementation of DurableObjects uses {% raw %}class
.
Add the following content to wrangler.toml
, which describes the Cloudflare worker settings:
[[durable_objects.bindings]]
class_name = "WebSocketConnection"
name = "WEBSOCKET"
[[migrations]]
new_classes = ["WebSocketConnection"]
tag = "v1" # Should be unique for each entry
class_name = "WebSocketConnection"
is linked to the actual JavaScript class name.
name = "WEBSOCKET"
is set so that DurableObjects can be accessed through c.env.WEBSOCKET
from within the worker code.
2. Env Configuration
To access WEBSOCKET within the Hono instance, define the type:
Copyimport type { DurableObjectNamespace } from '@cloudflare/workers-types'
type Env = {
Bindings: {
WEBSOCKET: DurableObjectNamespace
}
}
3. Define DurableObjects
Here's the implementation of DurableObjects.
I'm in the camp of wanting to write interfaces as much as possible, so I've written it, but it works without it too.
Perhaps because I've written "types": ["@cloudflare/workers-types"]
in tsconfig.json, I can use the DurableObject interface without importing it.
Rather, if I write import type { DurableObject } from '@cloudflare/workers-types'
, it results in an error for some reason.
export class WebSocketConnection implements DurableObject {
private readonly sessions = new Set<WebSocket>()
async fetch(request: Request) {
if (request.headers.get('Upgrade') !== 'websocket') {
return new Response('Expected WebSocket', { status: 426 })
}
const pair = new WebSocketPair()
const [client, server] = Object.values(pair)
await this.handleSession(server as WebSocket)
return new Response(null, {
status: 101,
webSocket: client,
})
}
private async handleSession(webSocket: WebSocket): Promise<void> {
(webSocket as any).accept()
this.sessions.add(webSocket)
webSocket.addEventListener('message', async (event: MessageEvent) => {
this.sessions.forEach((session) => session.readyState === WebSocket.OPEN && session.send(event.data))
})
webSocket.addEventListener('close', async (event: CloseEvent) => {
this.sessions.delete(webSocket)
})
}
}
fetch
is called for each request.
I'm not sure if the WebSocket type isn't being used correctly, but writing webSocket.accept()
results in an error.
I feel defeated, but I used an any cast.
Worker Implementation
Then I implemented the Worker.
hono has a helper called upgradeWebSocket, but I couldn't get it to work properly (maybe I was doing something wrong), so I implemented it like this:
const app = new Hono<Env>()
.use('/*', serveStatic({ root: './', manifest: {} }))
// @ts-ignore
.get('/ws', c => {
const upgradeHeader = c.req.header('Upgrade')
if (upgradeHeader !== 'websocket') {
return c.text('Expected WebSocket', 426)
}
const id = c.env.WEBSOCKET.idFromName('websocket')
const connection = c.env.WEBSOCKET.get(id)
// @ts-ignore
return connection.fetch(c.req.raw)
})
I was defeated at .get('/ws', c => {
and also at return connection.fetch(c.req.raw)
.
I thought, "My app works when I add @ts-ignore."
In other samples, it works with connection.fetch(c.req.raw)... maybe it's because of a different version?
This sample has a very easy-to-understand and concise WebSocket implementation, which was very helpful.
https://github.com/eduardvercaemer/showcase-hono-htmx-chatroom
Client
Initially, I was writing the client in hono too, but for some reason, I couldn't get WebSocket to work through the vite server with hono, so I decided to separate the client.
Since the client was almost complete, I looked for something as close to hono's jsx as possible.
Since hono's jsx uses use*, I thought about using React, but I remember seeing somewhere that hono doesn't use a virtual DOM, so I chose solid, which also manipulates the real DOM.
For styling, I used solid-styled-components, which is similar to the css helper I was using.
Basically, it's built on RxJS.
It was very easy to implement mouse operations, so it was very easy to create.
I also like that I don't have to look at solidjs's state to get brush size and color changes.
dragging$.pipe(
withLatestFrom(toolUpdated$.pipe(startWith<Tool>('rotate'))),
withLatestFrom(colorUpdated$.pipe(startWith<Color>(red))),
withLatestFrom(brushSizeUpdated$.pipe(startWith<number>(1))),
)
With just this, you can get the latest tool, color, and brushSize, which is nice.
However, breaking down the data in subscribe became complex, so I'd like to think about it again next time.
.subscribe(([[[interaction, tool], color], brushSize]) => {
})
By making each feature depend on RxJS streams, I was able to develop like this:
- Each feature can focus on its own role (when causing side effects, it issues a stream)
- App behavior can be centrally managed in client/src/app/flow.ts
Client's WebSocket
This is how I'm doing it.
In the sample, it was characteristic to ping every 20000 milliseconds with setInterval.
I don't fully understand it, but I think it's periodically accessing to maintain the connection.
const ws = new WebSocket(
</span><span class="p">${</span><span class="nx">location</span><span class="p">.</span><span class="nx">protocol</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">https:</span><span class="dl">'</span> <span class="p">?</span> <span class="dl">'</span><span class="s1">wss:</span><span class="dl">'</span> <span class="p">:</span> <span class="dl">'</span><span class="s1">ws:</span><span class="dl">'</span><span class="p">}</span><span class="s2">//</span><span class="p">${</span><span class="nx">location</span><span class="p">.</span><span class="nx">host</span><span class="p">}</span><span class="s2">/ws
)
setInterval(() => ws.readyState === 1 && ws.send('ping'), 20000)
ws.addEventListener('message', (event) => {
try {
var json = JSON.parse(event.data)
} catch {
return
}
if (json.tool === 'pull') {
renderer.pull({
phase: json.phase,
x: json.x,
y: json.y,
brushSize: json.brushSize,
color: ColorClass.fromString(json.color),
})
} else if (json.tool === 'push') {
renderer.push({
phase: json.phase,
x: json.x,
y: json.y,
brushSize: json.brushSize,
color: ColorClass.fromString(json.color),
})
}
})
Conclusion
I found DurableObjects' WebSocket very easy to use and felt that it could keep costs down.
I would like to continue thinking about how to develop more advanced apps using Cloudflare while keeping running costs down.
PR
VOTE, which I'm involved in developing at blue Inc., is a web app where you vote on binary topics.
Please feel free to try it out with topics ranging from technical issues to everyday choices.
Posted on June 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.