Build

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.

7 steps · about 25 minutes · uses serve, ws, json, thread

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.

What you'll have at the end
200 true
* welcome, you are guest1
Step 1 of 7

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.

Your turn

Actions

Make describe show an action message as * ann waves.

main.osl
Ctrl Enter runs
Expected output
ann: hello!
* ann waves
main.osl
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"}`)
Click the code to edit it
Step 2 of 7

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)
Click the code to edit it

Highlighted lines are new in this step.

Step 3 of 7

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.

Quick check

What is the difference between conn.send(x) and room.broadcast(x)?

Your turn

Mind your language

Make clean replace darn with ****. In the server you would clean m.text before broadcasting.

main.osl
Ctrl Enter runs
Expected output
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)
Click the code to edit it

Highlighted lines are new in this step.

Step 4 of 7

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.

Quick check

bo joined as guest2 and never sent a nick. What name does the room see on bo's chat lines?

Your turn

A valid nickname

Return true only for names of 1 to 12 characters with no spaces.

main.osl
Ctrl Enter runs
Expected output
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)
Click the code to edit it

Highlighted lines are new in this step.

Step 5 of 7

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.

Your turn

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.

main.osl
Ctrl Enter runs
Expected output
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)
Click the code to edit it

Highlighted lines are new in this step.

Step 6 of 7

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)
Click the code to edit it

Highlighted lines are new in this step.

Step 7 of 7

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:

osl
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.