Serve WebSocket
A WebSocket server in RUSM is one sandboxed process per connection. The host owns the socket; each inbound frame arrives as a message, and you reply with send. We'll build an echo server, then run it.
1. Declare the listener
WebSocket (like SSE) isn't routed — it runs one handler component per connection, so you name it directly with component = "...":
[[serve]]
protocol = "ws"
component = "chat" # one process per connection → ./wasm/chat.{wasm,js}
listen = "127.0.0.1:8082"
[components.chat]
capability = "sandboxed"2. Write the handler
Unlike SSE, WebSocket is bidirectional: the client sends frames and the server receives them. But a WS handler is also a full actor process — so message fires for two distinct sources:
- A frame from the client — the browser sent something.
- An actor message from another process — a peer connection relayed something via
Process.send/rusm_rs::send_bytes(the same tag-broadcast mechanism SSE uses).
Both arrive as raw bytes. Your handler distinguishes them — typically by the shape of a JSON envelope. This is what makes a chat room possible with no broker: each connection joins a tag, clients broadcast via whereisTag + send, and peers' relays land in the same mailbox.
Three callbacks — open, message, close:
open(socket)— the client connected.socket.send()pushes a frame to this client. A good place to join a broadcast group withregisterTag.message(socket, data)— either a client frame or an actor message arrived. Parsedatato decide: if it's a command from the client, act on it (join a room, fan out to peers); if it's a relay from a peer, forward it to the client withsocket.send().close(socket)— the client disconnected. The tag membership releases automatically; this is optional cleanup.
One handler instance per connection — its state is private to that client.
Here's a minimal chat room — client sends {"join":"general"} then {"say":"hello"}; peers' relays arrive as {"from":"<pid>","text":"hello"}:
// components/chat/index.ts
import { websocket, Process, type Socket } from "rusm-ts";
let room: string | null = null; // this connection's current room (per-connection state)
const tag = (name: string) => `room:${name}`;
const reply = (s: Socket, text: string) => s.send(JSON.stringify({ reply: text }));
export default websocket({
open(socket) {
reply(socket, "connected");
},
message(socket, data) {
const msg = JSON.parse(new TextDecoder().decode(data));
if (typeof msg.join === "string") {
// Client wants to join a room: tag this process so broadcasts reach it.
room = msg.join;
Process.registerTag(tag(room));
reply(socket, `welcome to #${room}`);
return;
}
if (typeof msg.say === "string") {
// Client sent a chat message: fan it out to every connection in this room.
if (!room) return reply(socket, "join a room first");
const relay = JSON.stringify({ from: String(Process.self()), text: msg.say });
for (const pid of Process.whereisTag(tag(room))) Process.send(pid, relay);
return;
}
// A relay from a peer arrived in the mailbox — forward it to this client.
if (typeof msg.text === "string") socket.send(data);
},
close() {},
});// components/chat/src/lib.rs
use rusm_rs::ws::{self, Connection, Handler};
use serde::Deserialize;
use serde_json::json;
#[derive(Default)]
struct Chat { room: Option<String> }
#[derive(Deserialize)]
struct Frame { join: Option<String>, say: Option<String>, text: Option<String> }
impl Chat {
fn tag(room: &str) -> String { format!("room:{room}") }
fn reply(conn: &Connection, text: &str) {
conn.send(json!({ "reply": text }).to_string().as_bytes());
}
}
impl Handler for Chat {
fn open(&mut self, conn: &Connection) {
Self::reply(conn, "connected");
}
fn message(&mut self, conn: &Connection, data: Vec<u8>) {
let Ok(frame) = serde_json::from_slice::<Frame>(&data) else { return };
if let Some(room) = frame.join {
// Client wants to join a room: tag this process so broadcasts reach it.
rusm_rs::register_tag(&Self::tag(&room));
Self::reply(conn, &format!("welcome to #{room}")); // e.g. "welcome to #general"
self.room = Some(room);
return;
}
if let Some(say) = frame.say {
// Client sent a chat message: fan it out to every connection in this room.
let Some(room) = &self.room else { return Self::reply(conn, "join a room first") };
let relay = json!({ "from": rusm_rs::me().to_string(), "text": say }).to_string();
for pid in rusm_rs::whereis_tag(&Self::tag(room)) {
rusm_rs::send_bytes(pid, relay.as_bytes());
}
return;
}
// A relay from a peer arrived in the mailbox — forward it to this client.
if frame.text.is_some() { conn.send(&data); }
}
fn close(&mut self, _conn: &Connection) {}
}
#[rusm_rs::main]
fn run() { ws::serve(Chat::default()); }// components/chat/main.go
package main
import (
"encoding/json"
rusm "github.com/archan937/rusm/packages/rusm-go"
"github.com/archan937/rusm/packages/rusm-go/web"
)
func init() { rusm.Run(run) }
func main() {}
// frame is the inbound wire shape; pointers tell an absent field from an empty one.
type frame struct {
Join *string `json:"join,omitempty"`
Say *string `json:"say,omitempty"`
Text *string `json:"text,omitempty"`
}
func roomTag(room string) string { return "room:" + room }
func reply(c web.Conn, text string) {
if b, err := json.Marshal(map[string]string{"reply": text}); err == nil {
c.Send(b)
}
}
func run() {
var room string // this connection's room (one handler instance per connection)
web.WebSocket{
Open: func(c web.Conn) { reply(c, "connected") },
Message: func(c web.Conn, data []byte) {
var f frame
if json.Unmarshal(data, &f) != nil {
return
}
switch {
case f.Join != nil:
// Client wants to join a room: tag this process so broadcasts reach it.
room = *f.Join
rusm.RegisterTag(roomTag(room))
reply(c, "welcome to #"+room) // e.g. "welcome to #general"
case f.Say != nil:
// Client sent a chat message: fan it out to every connection in this room.
if room == "" {
reply(c, "join a room first")
return
}
relay, _ := json.Marshal(map[string]string{
"from": rusm.Self().String(), "text": *f.Say,
})
for _, pid := range rusm.WhereisTag(roomTag(room)) {
rusm.SendBytes(pid, relay)
}
case f.Text != nil:
// A relay from a peer arrived in the mailbox — forward it to this client.
c.Send(data)
}
},
Close: func(_ web.Conn) {},
}.Serve()
}3. Build, serve, test
rusm build
rusm serve # chat → ws://127.0.0.1:8082
# in another shell (Bun's WebSocket):
bun -e 'const w=new WebSocket("ws://127.0.0.1:8082");w.onmessage=e=>console.log(""+e.data);w.onopen=()=>w.send("hi")'
# → welcome
# → hiHow it runs
Each connection is a fresh sandboxed process; when the client disconnects (clean close or a dropped socket) your close fires once and the process exits — the runtime reclaims everything it held. A crash in one connection's handler drops that connection only; every other client and the listener are untouched.
Talking to many clients at once (a chat room fanning one message to its members) doesn't go through shared state — each connection tags itself with a process-group tag and a publisher broadcasts to the tag. That's its own pattern: Broadcast to many. Cross-connection state (presence counts, history) belongs in a stateful service or kv, never in the per-connection process.
Next: Serve SSE. For the execution model + failure modes, see the serving model.