zokor

Zokor documentation

Chat service

A Slang chat service using Zokor, with WebSocket and Socket.IO clients connected to the same room.

Markdown · llms.txt · api.json

A chat service: the same room for WebSocket clients and socket.io clients, on one server.

Run it from the repository root: make example-chat

examples/chat/main.sl#

examples/chat/main.sl
// A chat service: the same room for WebSocket clients and socket.io
// clients, on one server.
//
//     slangc examples/chat/main.sl --run            the scripted demo below
//     slangc examples/chat/main.sl --run -- serve   a real server on :8080
//
// With `serve`, point a browser at it:
//
//     const ws = new WebSocket("ws://localhost:8080/ws?name=ada", "chat");
//     ws.onmessage = (m) => console.log(m.data);
//     ws.send("hello");
//
//     const socket = io("http://localhost:8080", { query: { name: "leo" } });
//     socket.on("chat message", (m) => console.log(m));
//     socket.emit("chat message", "hi", (reply) => console.log(reply));
//
// Read "the service": that is everything a real one writes. The demo after
// it is a stand-in browser -- real connections to a real server on a free
// port -- so `make example-chat` shows the exchange without one.
import "http";
import "proc";
import "time";
import "../../zokor" as zokor;

// ---------------------------------------------------------------- //
// the service                                                        //
// ---------------------------------------------------------------- //

gc struct App {
    name: str,
    // everyone in the chat, WebSocket and socket.io alike
    hub: zokor.Hub,
    history: [str],
    lock: mutex,
}

fn remember(app: App, line: str) {
    mutex_lock(app.lock);
    push(app.history, line);
    mutex_unlock(app.lock);
}

// The handshake, for both kinds of client. Every `before` has run by now
// (an auth hook would have set c.local("user")); this one only wants a name.
fn arrive(c: zokor.Ctx[App]) -> http.Response {
    let name = c.query("name");
    if name == "" {
        return zokor.respond_with(c.errors, "unauthorized", "say who you are: ?name=",
                                  c.request_id);
    }
    let s = c.socket();
    s.set_local("name", name);
    c.state.hub.join_room(s, "chat");
    return c.accept_ws();
}

// WebSocket clients: every text message goes to the room.
fn on_text(app: App, s: zokor.Socket, m: zokor.Message) {
    if m.kind != zokor.Incoming.Text {
        s.send_text("only text, please");
        return;
    }
    if m.text == "/history" {
        mutex_lock(app.lock);
        let h = join_lines(app.history);
        mutex_unlock(app.lock);
        s.send_text(h);
        return;
    }
    let line = s.local("name") + ": " + m.text;
    remember(app, line);
    // to WebSocket clients as text, to socket.io clients as an event
    app.hub.to_room("chat", line, s.id);
    app.hub.emit_to_room("chat", "/", "chat message", zokor.jstr(line).render(), s.id);
}

fn on_ws_close(app: App, s: zokor.Socket, code: int, reason: str) {
    remember(app, s.local("name") + " left (" + to_str(code) + ")");
}

// socket.io clients.
fn on_event(app: App, s: zokor.Socket, e: zokor.SioEvent) {
    if e.kind != zokor.SioKind.Event {
        return;
    }
    if e.name == "chat message" {
        remember(app, s.local("name") + " said " + e.args_json);
        app.hub.emit_to_room("chat", e.namespace, "chat message", e.args_json, s.id);
        let said = "";
        let pr = zokor.parse(e.args_json);
        if let args = pr {
            said = args.at(0).str_or("");
        }
        app.hub.to_room("chat", s.local("name") + ": " + said, s.id);
        if e.ack >= 0 {
            // the client used socket.emit(..., callback)
            s.ack(e.namespace, e.ack, "[\"delivered\"]");
        }
        return;
    }
    s.emit(e.namespace, "error", "\"unknown event: " + e.name + "\"");
}

fn on_sio_leave(app: App, s: zokor.Socket, ns: str, why: str) {
    remember(app, s.local("name") + " left " + ns + " (" + why + ")");
}

fn chat_router(app: App) -> zokor.Router[App] {
    let r = zokor.new_router(app);
    r.websocket("/ws", arrive,
                zokor.ws_route(zokor.ws_allowing(["chat"]), on_text).on_close(on_ws_close));
    r.socketio("/socket.io/", arrive,
               zokor.sio_route(on_event).on_disconnect(on_sio_leave));
    return r;
}

fn join_lines(xs: [str]) -> str {
    let out = "";
    let i = 0;
    while i < len(xs) {
        if i > 0 {
            out = out + " | ";
        }
        out = out + xs[i];
        i = i + 1;
    }
    return out;
}

fn new_app() -> App {
    return App { name: "zokor chat", hub: zokor.new_hub(), history: [], lock: make_mutex() };
}

// ---------------------------------------------------------------- //
// the demo: a stand-in browser, printed                              //
//                                                                    //
// Real connections to the server above. Nothing below is part of     //
// zokor's API: a browser does all of this for you.                   //
// ---------------------------------------------------------------- //

import "../../zokor/internal/ws" as frames;

fn soon() -> until {
    return until_of(time.mono() + 2000000000);
}

// A client's frames are masked, which the server requires.
fn client_frame(op: int, payload: bytes) -> bytes {
    let n = len(payload);
    let head: bytes = b"..";
    head[0] = 128 | op;
    head[1] = 128 | n;
    let key = b"\x2a\x07\x63\x01";
    let body = b"";
    let one: bytes = b".";
    let i = 0;
    while i < n {
        one[0] = payload[i] ^ key[i % 4];
        body = body + one;
        i = i + 1;
    }
    return head + key + body;
}

fn says(c: &mut link, s: str) {
    let _s = c.send_bytes(client_frame(1, to_bytes(s)), soon());
}

fn bye(c: &mut link) {
    let b: bytes = b"..";
    b[0] = 3;
    b[1] = 232;
    let _s = c.send_bytes(client_frame(8, b), soon());
}

// Sends the upgrade request and returns the status line; whatever came
// after the response head is kept in `got`.
gc struct Inbox {
    got: bytes,
}

fn open(c: &mut link, path: str, box: Inbox) -> str {
    let req = "GET " + path + " HTTP/1.1\r\nHost: localhost\r\nUpgrade: websocket\r\n" +
              "Connection: Upgrade\r\nSec-WebSocket-Version: 13\r\n" +
              "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n" +
              "Sec-WebSocket-Protocol: chat\r\n\r\n";
    let _s = c.send_bytes(to_bytes(req), soon());
    let a = arena_new(4096);
    let w = a.wire(4096);
    let got = b"";
    while true {
        let rr = c.recv(w, soon());
        guard let n = rr else { return "no answer"; }
        if n == 0 { return "closed"; }
        got = got + to_bytes(w[0..n]);
        let i = 0;
        while i + 3 < len(got) {
            if got[i] == 13 && got[i + 1] == 10 && got[i + 2] == 13 && got[i + 3] == 10 {
                box.got = got[i + 4..len(got)];
                let j = 0;
                while got[j] != 13 { j = j + 1; }
                return to_str(got[0..j]);
            }
            i = i + 1;
        }
    }
    return "no answer";
}

// The next frame from the server, printed as a browser would see it.
fn hears(label: str, c: &mut link, box: Inbox) {
    let a = arena_new(4096);
    let w = a.wire(4096);
    while true {
        let dr = frames.decode(box.got, 0, 1048576, false);
        guard let d = dr else { println(label + " <- (undecodable)"); return; }
        if d.kind == frames.Decoded.Frame {
            let f = d.frame;
            box.got = box.got[f.size..len(box.got)];
            if f.op == frames.Op.Text {
                println(label + " <- " + to_str(f.payload));
            } else if f.op == frames.Op.Close {
                let cr = frames.decode_close(f.payload);
                if let body = cr {
                    println(label + " <- close " + to_str(body.code) + " " + body.reason);
                } else {
                    println(label + " <- close");
                }
            } else {
                println(label + " <- frame " + to_str(frames.code_of(f.op)));
            }
            return;
        }
        let rr = c.recv(w, soon());
        guard let n = rr else { println(label + " <- (nothing)"); return; }
        if n == 0 { println(label + " <- (connection closed)"); return; }
        box.got = box.got + to_bytes(w[0..n]);
    }
}

fn serve_all(ln: link, r: zokor.Router[App]) {
    let sc = zokor.default_server_config();
    sc.logger = zokor.no_logger();
    let mut_ln = ln;
    while true {
        let ar = mut_ln.accept(until_never());
        guard let c = ar else { return; }
        spawn zokor.serve_conn(r, c, sc);
    }
}

// Waits until the server has finished with a client that left, so the
// history printed at the end is complete.
fn settle(app: App, lines: int) {
    let waited = 0;
    while waited < 200 {
        mutex_lock(app.lock);
        let n = len(app.history);
        mutex_unlock(app.lock);
        if n >= lines { return; }
        time.sleep(10000000);
        waited = waited + 1;
    }
}

fn demo() {
    let app = new_app();
    let r = chat_router(app);
    let lr = link_listen(0);
    guard let ln = lr else { panic("cannot listen"); }
    let port = ln.port();
    spawn serve_all(ln, r);

    println("websocket:");
    let ada = link_dial("127.0.0.1", port, soon()) ?? panic_link();
    let ab = Inbox { got: b"" };
    println("  ada  handshake -> " + open(&mut ada, "/ws?name=ada", ab));
    let bo = link_dial("127.0.0.1", port, soon()) ?? panic_link();
    let bb = Inbox { got: b"" };
    println("  bo   handshake -> " + open(&mut bo, "/ws?name=bo", bb));
    let anon = link_dial("127.0.0.1", port, soon()) ?? panic_link();
    let nb = Inbox { got: b"" };
    println("  anon handshake -> " + open(&mut anon, "/ws", nb));
    says(&mut ada, "hello");
    hears("  bo  ", &mut bo, bb);
    says(&mut bo, "hi ada");
    hears("  ada ", &mut ada, ab);
    println("");

    println("socket.io:");
    let leo = link_dial("127.0.0.1", port, soon()) ?? panic_link();
    let lb = Inbox { got: b"" };
    println("  leo  handshake -> " +
            open(&mut leo, "/socket.io/?EIO=4&transport=websocket&name=leo", lb));
    // the session id is random; the rest of the open packet is not
    let a = arena_new(4096);
    let w = a.wire(4096);
    let opened = false;
    while !opened {
        let dr = frames.decode(lb.got, 0, 1048576, false);
        guard let d = dr else { break; }
        if d.kind == frames.Decoded.Frame {
            let f = d.frame;
            lb.got = lb.got[f.size..len(lb.got)];
            let text = to_str(f.payload);
            let at = 0;
            let tb = to_bytes(text);
            while at < len(tb) && tb[at] != 44 { at = at + 1; }
            println("  leo  <- 0{\"sid\":\"...\"" + to_str(tb[at..len(tb)]));
            opened = true;
        } else {
            let rr = a_recv(&mut leo, w);
            lb.got = lb.got + rr;
        }
    }
    says(&mut leo, "40");
    let _ok = skip_one(&mut leo, lb);
    println("  leo  -> 40           (connected to /)");
    says(&mut leo, "421[\"chat message\",\"hi from socket.io\"]");
    hears("  ada ", &mut ada, ab);
    hears("  bo  ", &mut bo, bb);
    hears("  leo ", &mut leo, lb);
    says(&mut ada, "and back");
    hears("  bo  ", &mut bo, bb);
    hears("  leo ", &mut leo, lb);
    says(&mut leo, "42[\"whatever\"]");
    hears("  leo ", &mut leo, lb);
    println("");

    println("leaving:");
    bye(&mut ada);
    hears("  ada ", &mut ada, ab);
    settle(app, 5);
    says(&mut leo, "41");
    settle(app, 6);
    says(&mut bo, "/history");
    hears("  bo  ", &mut bo, bb);
}

fn a_recv(c: &mut link, w: wire) -> bytes {
    let rr = c.recv(w, soon());
    guard let n = rr else { return b""; }
    return to_bytes(w[0..n]);
}

// The CONNECT answer carries the random session id too: read past it.
fn skip_one(c: &mut link, box: Inbox) -> bool {
    let a = arena_new(4096);
    let w = a.wire(4096);
    while true {
        let dr = frames.decode(box.got, 0, 1048576, false);
        guard let d = dr else { return false; }
        if d.kind == frames.Decoded.Frame {
            box.got = box.got[d.frame.size..len(box.got)];
            return true;
        }
        let rr = c.recv(w, soon());
        guard let n = rr else { return false; }
        if n == 0 { return false; }
        box.got = box.got + to_bytes(w[0..n]);
    }
    return false;
}

fn panic_link() -> link {
    panic("cannot connect");
}

let args = proc.args();
if len(args) > 1 && args[len(args) - 1] == "serve" {
    let r = chat_router(new_app());
    let sr = zokor.listen_and_serve(r, 8080);
    guard let _ok = sr else let e = err_of(sr) {
        println(e);
        exit(1);
    }
} else {
    demo();
}