Build a realtime chat
Build a chat room with std:serve and std:ws: WebSocket handlers, nicknames kept on each connection, JSON messages, broadcast and a browser page.
You'll build a chat room: people connect, pick a nickname, and see everyone's messages as they happen. It uses a WebSocket server mounted on a std:serve router, and ends with a web page.
std:ws also has a client, so every step runs here: the try-it tab starts the server, connects clients and prints what they receive.
200 true * welcome, you are guest1
Decide what messages look like
Both sides speak JSON with a type. Clients send say and nick; the server sends chat, system and error. One struct covers them all. describe decodes a message with json.parse<Message> and turns it into a line of text, and a broken one becomes a ? line.
Try changing a message's type or text.
Actions
Make describe show an action message as * ann waves.
ann: hello! * ann waves
import "std:json"
struct Message (
string type = ""
string name = ""
string text = ""
)
def describe(string raw) string (
Message m = json.parse<Message>(raw) catch (e) (
return "? " ++ raw
)
switch m.type (
case "chat"
return m.name ++ ": " ++ m.text
case "system"
return "* " ++ m.text
default
return "! " ++ m.text
)
)
log describe(`{"type": "system", "text": "ann joined"}`)
log describe(`{"type": "chat", "name": "ann", "text": "hello!"}`)
log describe(`{"type": "error", "text": "unknown command"}`)Accept connections
ws.new() makes a WebSocket server and app.ws("/chat", room) mounts it on the router. room.on("connect", fn) runs for each new client. conn.set(key, value) keeps a value on that connection, which is where the nickname goes, and conn.send(object) sends JSON to that one client.
The try-it tab joins two clients. Each handler runs on its connection's own thread, so the test client drops what it receives into a channel and the main program waits for it with recv(). That keeps the output in a fixed order.
import "std:serve"
import "std:ws"
import "std:json"
import "std:thread"
struct Message (
string type = ""
string name = ""
string text = ""
)
def describe(string raw) string (
Message m = json.parse<Message>(raw) catch (e) (
return "? " ++ raw
)
switch m.type (
case "chat"
return m.name ++ ": " ++ m.text
case "system"
return "* " ++ m.text
default
return "! " ++ m.text
)
)
*serve.Router app = serve.new()
*ws.Server room = ws.new()
int guests = 0
room.on("connect", def(*ws.Connection conn) (
guests += 1
conn.set("name", "guest" ++ guests)
conn.send({type: "system", text: "welcome, you are " ++ conn.get("name")}).unwrap()
))
app.ws("/chat", room)Highlighted lines are new in this step.
Broadcast
room.on("message", fn) runs when any client sends something. A say message is decoded and sent to everyone with room.broadcast, the sender included, so everyone sees the same stream. Anything that isn't JSON gets an error back, to that client only.
What is the difference between conn.send(x) and room.broadcast(x)?
Mind your language
Make clean replace darn with ****. In the server you would clean m.text before broadcasting.
well **** it DARN!
import "std:serve"
import "std:ws"
import "std:json"
import "std:thread"
struct Message (
string type = ""
string name = ""
string text = ""
)
def describe(string raw) string (
Message m = json.parse<Message>(raw) catch (e) (
return "? " ++ raw
)
switch m.type (
case "chat"
return m.name ++ ": " ++ m.text
case "system"
return "* " ++ m.text
default
return "! " ++ m.text
)
)
*serve.Router app = serve.new()
*ws.Server room = ws.new()
int guests = 0
room.on("connect", def(*ws.Connection conn) (
guests += 1
conn.set("name", "guest" ++ guests)
conn.send({type: "system", text: "welcome, you are " ++ conn.get("name")}).unwrap()
))
room.on("message", def(*ws.Connection conn, string message) (
Message m = json.parse<Message>(message) catch (e) (
conn.send({type: "error", text: "send JSON"}).unwrap()
return
)
string name = conn.get("name")
switch m.type (
case "say"
room.broadcast({type: "chat", name: name, text: m.text})
default
conn.send({type: "error", text: "unknown type " ++ m.type}).unwrap()
)
))
app.ws("/chat", room)Highlighted lines are new in this step.
Nicknames
A nick message changes the name stored on the connection and tells the room. The case "nick" lines are new; the name is read back with conn.get("name") on every message, so a chat line carries the latest nickname.
bo joined as guest2 and never sent a nick. What name does the room see on bo's chat lines?
A valid nickname
Return true only for names of 1 to 12 characters with no spaces.
true false false false
import "std:serve"
import "std:ws"
import "std:json"
import "std:thread"
struct Message (
string type = ""
string name = ""
string text = ""
)
def describe(string raw) string (
Message m = json.parse<Message>(raw) catch (e) (
return "? " ++ raw
)
switch m.type (
case "chat"
return m.name ++ ": " ++ m.text
case "system"
return "* " ++ m.text
default
return "! " ++ m.text
)
)
*serve.Router app = serve.new()
*ws.Server room = ws.new()
int guests = 0
room.on("connect", def(*ws.Connection conn) (
guests += 1
conn.set("name", "guest" ++ guests)
conn.send({type: "system", text: "welcome, you are " ++ conn.get("name")}).unwrap()
))
room.on("message", def(*ws.Connection conn, string message) (
Message m = json.parse<Message>(message) catch (e) (
conn.send({type: "error", text: "send JSON"}).unwrap()
return
)
string name = conn.get("name")
switch m.type (
case "say"
room.broadcast({type: "chat", name: name, text: m.text})
case "nick"
conn.set("name", m.name)
room.broadcast({type: "system", text: name ++ " is now " ++ m.name})
default
conn.send({type: "error", text: "unknown type " ++ m.type}).unwrap()
)
))
app.ws("/chat", room)Highlighted lines are new in this step.
Announce who left
"disconnect" fires when a connection closes from either side. Closing with wait: true returns once the socket is closed, so the server has seen it by the time ann reads the news.
Try changing the try-it tab: have bo say something first.
Say who's left
Make leftText say how many people remain: bo left, 1 here, or ann left, the room is empty when nobody's left.
bo left, 1 here ann left, the room is empty
import "std:serve"
import "std:ws"
import "std:json"
import "std:thread"
struct Message (
string type = ""
string name = ""
string text = ""
)
def describe(string raw) string (
Message m = json.parse<Message>(raw) catch (e) (
return "? " ++ raw
)
switch m.type (
case "chat"
return m.name ++ ": " ++ m.text
case "system"
return "* " ++ m.text
default
return "! " ++ m.text
)
)
*serve.Router app = serve.new()
*ws.Server room = ws.new()
int guests = 0
room.on("connect", def(*ws.Connection conn) (
guests += 1
conn.set("name", "guest" ++ guests)
conn.send({type: "system", text: "welcome, you are " ++ conn.get("name")}).unwrap()
))
room.on("message", def(*ws.Connection conn, string message) (
Message m = json.parse<Message>(message) catch (e) (
conn.send({type: "error", text: "send JSON"}).unwrap()
return
)
string name = conn.get("name")
switch m.type (
case "say"
room.broadcast({type: "chat", name: name, text: m.text})
case "nick"
conn.set("name", m.name)
room.broadcast({type: "system", text: name ++ " is now " ++ m.name})
default
conn.send({type: "error", text: "unknown type " ++ m.type}).unwrap()
)
))
room.on("disconnect", def(*ws.Connection conn) (
string name = conn.get("name")
room.broadcast({type: "system", text: name ++ " left"})
))
app.ws("/chat", room)Highlighted lines are new in this step.
Serve a page
The same router serves the browser client at /. It opens a socket to /chat, shows what arrives and sends what you type as the JSON from step 1. Typing /nick ann sends a nick message. The try-it tab fetches the page and checks the client is in it.
import "std:serve"
import "std:ws"
import "std:json"
import "std:thread"
import "std:requests"
struct Message (
string type = ""
string name = ""
string text = ""
)
def describe(string raw) string (
Message m = json.parse<Message>(raw) catch (e) (
return "? " ++ raw
)
switch m.type (
case "chat"
return m.name ++ ": " ++ m.text
case "system"
return "* " ++ m.text
default
return "! " ++ m.text
)
)
*serve.Router app = serve.new()
*ws.Server room = ws.new()
int guests = 0
string page = `<!doctype html>
<meta charset="utf-8">
<title>Chat</title>
<div id="log"></div>
<form id="form">
<input id="text" autocomplete="off" placeholder="Say something, or /nick name">
<button>Send</button>
</form>
<script>
var log = document.getElementById("log");
var scheme = location.protocol === "https:" ? "wss://" : "ws://";
var socket = new WebSocket(scheme + location.host + "/chat");
function add(text) {
var line = document.createElement("div");
line.textContent = text;
log.appendChild(line);
}
socket.onmessage = function (event) {
var m = JSON.parse(event.data);
add(m.type === "chat" ? m.name + ": " + m.text : "* " + m.text);
};
document.getElementById("form").onsubmit = function (event) {
event.preventDefault();
var box = document.getElementById("text");
var text = box.value.trim();
if (text.startsWith("/nick ")) {
socket.send(JSON.stringify({type: "nick", name: text.slice(6)}));
} else if (text) {
socket.send(JSON.stringify({type: "say", text: text}));
}
box.value = "";
};
</script>
`
app.get("/", def(*serve.Context c) (
c.html(200, page)
))
room.on("connect", def(*ws.Connection conn) (
guests += 1
conn.set("name", "guest" ++ guests)
conn.send({type: "system", text: "welcome, you are " ++ conn.get("name")}).unwrap()
))
room.on("message", def(*ws.Connection conn, string message) (
Message m = json.parse<Message>(message) catch (e) (
conn.send({type: "error", text: "send JSON"}).unwrap()
return
)
string name = conn.get("name")
switch m.type (
case "say"
room.broadcast({type: "chat", name: name, text: m.text})
case "nick"
conn.set("name", m.name)
room.broadcast({type: "system", text: name ++ " is now " ++ m.name})
default
conn.send({type: "error", text: "unknown type " ++ m.type}).unwrap()
)
))
room.on("disconnect", def(*ws.Connection conn) (
string name = conn.get("name")
room.broadcast({type: "system", text: name ++ " left"})
))
app.ws("/chat", room)Highlighted lines are new in this step.
Run it for real
The try-it tab starts and stops the server itself. For the real thing, save the program above without the try-it part and end it with app.run, which serves until the program stops:
log "chat running at http://localhost:8080"
app.run(":8080").unwrap()Run it with osl run chat.osl, open http://localhost:8080 in two tabs and type /nick ann in one. Next: ws, serve, or a desktop app.