radlang — Networking (tcp, websocket, sse, Channel)

This document covers radlang’s lower-level networking: raw TCP sockets, WebSockets, Server-Sent Events (SSE), and Channel, the primitive that makes HTTP long-polling practical.

For the HTTP client and server see http.md.


Overview

All of these are compiler-intrinsic: the names tcp, websocket, sse, and the Channel constructor are reserved and dispatched straight to the runtime, so they need no import.

They all ride the same cooperative scheduler as the HTTP server. A server runs one coroutine per connection, and a call that would block (a read with no data yet, a write to a full buffer) yields to the scheduler so other connections keep being served. In synchronous client code on the main thread, the same calls simply block the thread, which is what a client wants.

A recurring convention: a long-lived endpoint reports liveness through isOpen(), and its read call returns an empty string once the peer has gone away. So the idiomatic server loop is:

while (conn.isOpen()) {
    string data = conn.read()
    if (data != "") {
        // handle data
    }
}

When the peer disconnects, read() returns "" and flips isOpen() to false, ending the loop.


TCP

Server

tcp.listen(port: int) : TcpServer

server.onConnection(handler)    // handler: fn(TcpConn): void
server.start()  : void          // run on a background thread; returns immediately
server.listen() : void          // run in the foreground; blocks
server.close()  : void          // stop accepting

Register a single connection handler, then start the server. Each accepted connection runs the handler on its own coroutine.

fn main() {
    TcpServer server = tcp.listen(9000)

    server.onConnection(fn(TcpConn conn): void {
        while (conn.isOpen()) {
            string data = conn.read()
            if (data != "") {
                conn.write("echo:" + data)
            }
        }
    })

    server.listen()   // blocks, serving connections
}

Client

tcp.connect(host: string, port: int, timeoutMs?: int) : TcpConn

Connection methods

conn.read()  : string     // next available bytes; "" on EOF/close
conn.readTimeout(timeoutMs: int) : string  // "" on timeout; plaintext TCP stays open
conn.write(data: string) : void
conn.writeTimeout(data: string, timeoutMs: int) : bool
conn.startTls(serverName: string, caPath: string, timeoutMs?: int) : bool
conn.close() : void
conn.isOpen(): bool

startTls upgrades the current connection in place, so use it immediately after the server accepts SMTP’s STARTTLS command. caPath is a PEM bundle used for certificate and hostname validation; the call fails closed if it is missing (or use RADLANG_TLS_CA). acceptTls(certPath, keyPath) is available on an accepted raw connection for protocol servers that negotiate TLS after a plaintext greeting.

read() returns whatever bytes are next available in one read; it does not wait for a specific length or a delimiter. Loop until you have what you need.

fn main() {
    TcpConn conn = tcp.connect("127.0.0.1", 9000)
    conn.write("hello")
    string reply = conn.read()
    sys.output(reply)
    conn.close()
}

WebSockets

WebSockets are served by the HTTP server: register an upgrade route and radlang performs the RFC 6455 handshake for you, then hands your handler a live WebSocket.

Server

server.ws(path: string, handler)   // handler: fn(WebSocket): void  (Socket is an alias)
fn main() {
    HttpServer server = httpserver.serve(8080)

    server.ws("/chat", fn(WebSocket ws): void {
        while (ws.isOpen()) {
            string msg = ws.receive()
            if (msg != "") {
                ws.send("echo: " + msg)
            }
        }
    })

    server.listen()
}

The handler runs for the lifetime of the connection. It coexists with normal routes on the same server, so one process can serve pages, a REST API, and a WebSocket endpoint together.

Event handlers (ws.on + ws.listen)

Instead of writing the while (ws.isOpen()) loop by hand, you can attach lifecycle callbacks and let ws.listen() pump the connection. The handler argument may be spelled Socket (an alias for WebSocket):

server.ws("/chat", fn(Socket ws): void {
    ws.on("connect",    fn(): void { sys.output("client connected") })
    ws.on("message",    fn(string msg): void { ws.send("echo: " + msg) })
    ws.on("disconnect", fn(): void { sys.output("client left") })
    ws.listen()      // blocks, dispatching messages until the socket closes
})
  • connect — fires immediately when registered (the socket is already open when the handler runs). Callback: fn(): void.
  • message — fires once per received message while ws.listen() runs. Callback: fn(string msg): void.
  • disconnect — fires exactly once when the peer closes or the connection drops (from receive(), listen(), or an explicit close()). Callback: fn(): void.

ws.listen() is the event-loop entry point: it receives frames and dispatches each message to your "message" handler, returning when the socket closes. You can still call ws.send() / ws.close() from inside any handler.

Memory note. ws.listen() frees each message’s allocations after the "message" handler returns, so a long-lived connection stays flat in memory — this is the leak-resistant alternative to a hand-written isOpen() loop. The contract: a "message" handler must be transient (process and forget — echo, log, broadcast by send()). It must not retain the message past the callback by mutating an outer or persistent structure (e.g. appending it to a captured list); that memory is reclaimed on the next message. To accumulate, write to a sink outside the connection (a database, another socket).

Client

websocket.connect(url: string) : WebSocket    // ws:// only (see TLS note below)

WebSocket methods

ws.receive() : string     // next text message; "" once closed
ws.send(msg: string) : void
ws.close()   : void
ws.isOpen()  : bool
ws.on(event: string, callback) : void   // "connect" | "message" | "disconnect"
ws.listen()  : void       // dispatch messages to on("message") until closed

receive() returns one complete message. Fragmented messages are reassembled for you, and ping/pong is handled internally (a ping is answered with a pong without surfacing to your code). A close frame from the peer ends the stream: receive() returns "" and isOpen() becomes false.

fn main() {
    WebSocket ws = websocket.connect("ws://127.0.0.1:8080/chat")
    ws.send("hello")
    sys.output(ws.receive())     // echo: hello
    ws.close()
}

Server-Sent Events (SSE)

SSE is a long-lived HTTP response the server keeps appending events to. It is one-directional: server to client.

Server

server.sse(path: string, handler)   // handler: fn(HttpRequest, SseStream): void

radlang sends the text/event-stream response headers, then invokes your handler with the request and a writable stream.

fn main() {
    HttpServer server = httpserver.serve(8080)

    server.sse("/events", fn(HttpRequest req, SseStream stream): void {
        int i = 0
        while (stream.isOpen() && i < 10) {
            stream.send("tick " + i.toString())
            sys.sleep(1000)
            i++
        }
    })

    server.listen()
}

Stream methods

stream.send(data: string)              : void   // a "data:" event
stream.event(name: string, data: string): void   // a named event
stream.isOpen()                        : bool   // false once the client disconnects
stream.close()                         : void

Multi-line data is split into the multiple data: lines the SSE spec requires. isOpen() detects a client that has navigated away, so a push loop can stop.

Client

sse.connect(url: string) : SseClient    // http:// only (see TLS note below)

client.receive() : string     // the data of the next event; "" once closed
client.isOpen()  : bool
client.close()   : void
fn main() {
    SseClient client = sse.connect("http://127.0.0.1:8080/events")
    while (client.isOpen()) {
        string event = client.receive()
        if (event != "") {
            sys.output(event)
        }
    }
}

receive() returns the data payload of the next event (multiple data: lines are joined with newlines). Named-event, id, and retry fields are not surfaced; only the data is returned.


Channel — long-polling and coroutine hand-off

A Channel lets one coroutine wait for a value another coroutine will produce later, without blocking the OS thread. Its motivating use is HTTP long-polling: a request handler parks on the channel until some other request produces data or a timeout fires, and meanwhile the server keeps handling other connections.

Channel()                          : Channel   // constructor
ch.send(value: string)             : void      // hand a value to a waiter, or buffer it
ch.receive()                       : string    // wait for the next value
ch.receiveTimeout(ms: int)         : string    // wait up to ms; "" on timeout
ch.isOpen()                        : bool
ch.close()                         : void       // wakes every waiter with ""

receive() / receiveTimeout() suspend the calling coroutine, so they are only valid inside a coroutine (an HTTP/WS/SSE handler, or an async fn), never in plain synchronous main code. send() is safe to call anywhere: if a receiver is waiting it is handed the value directly, otherwise the value is buffered for the next receive().

Long-polling with HTTP

fn main() {
    HttpServer server = httpserver.serve(8080)
    Channel updates = Channel()

    // A client parks here until someone posts, or 30s passes.
    server.get("/poll", fn(HttpRequest req): HttpResponse {
        string msg = updates.receiveTimeout(30000)
        if (msg == "") {
            return HttpResponse(204, "", "text/plain")   // nothing yet; client retries
        }
        return HttpResponse(200, msg, "text/plain")
    })

    // Any post wakes a parked poller.
    server.post("/publish", fn(HttpRequest req): HttpResponse {
        updates.send(req.body)
        return HttpResponse(200, "ok", "text/plain")
    })

    server.listen()
}

Because the poll handler yields while parked, one server thread can hold many long-poll requests open at once.


TLS

Like the HTTP listener, none of these speak TLS. websocket.connect accepts ws:// (not wss://) and sse.connect accepts http:// (not https://). For public traffic, terminate TLS at a reverse proxy (nginx, Caddy, or a cloud load balancer) and forward plaintext to the radlang server on localhost.


Quick reference

CallReturnsPurpose
tcp.listen(port)TcpServerCreate a TCP listener
tcp.connect(host, port)TcpConnOpen a TCP client connection
server.onConnection(fn) / listen() / start() / close()voidRegister handler / run / run in background / stop
conn.read() / write(s) / close() / isOpen()string / void / void / boolConnection I/O
server.ws(path, fn)voidRegister a WebSocket upgrade route
websocket.connect(url)WebSocketOpen a WebSocket client (ws://)
ws.receive() / send(s) / close() / isOpen()string / void / void / boolWebSocket I/O
ws.on(event, cb) / ws.listen()void / voidEvent handlers (connect/message/disconnect) + message pump
server.sse(path, fn)voidRegister an SSE route
stream.send(s) / event(name, s) / isOpen() / close()void / void / bool / voidServer push
sse.connect(url)SseClientConsume an event stream (http://)
client.receive() / isOpen() / close()string / bool / voidConsume events
Channel()ChannelCreate a channel
ch.send(s) / receive() / receiveTimeout(ms) / isOpen() / close()void / string / string / bool / voidHand-off / long-poll