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 whilews.listen()runs. Callback:fn(string msg): void.disconnect— fires exactly once when the peer closes or the connection drops (fromreceive(),listen(), or an explicitclose()). 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
| Call | Returns | Purpose |
|---|---|---|
tcp.listen(port) | TcpServer | Create a TCP listener |
tcp.connect(host, port) | TcpConn | Open a TCP client connection |
server.onConnection(fn) / listen() / start() / close() | void | Register handler / run / run in background / stop |
conn.read() / write(s) / close() / isOpen() | string / void / void / bool | Connection I/O |
server.ws(path, fn) | void | Register a WebSocket upgrade route |
websocket.connect(url) | WebSocket | Open a WebSocket client (ws://) |
ws.receive() / send(s) / close() / isOpen() | string / void / void / bool | WebSocket I/O |
ws.on(event, cb) / ws.listen() | void / void | Event handlers (connect/message/disconnect) + message pump |
server.sse(path, fn) | void | Register an SSE route |
stream.send(s) / event(name, s) / isOpen() / close() | void / void / bool / void | Server push |
sse.connect(url) | SseClient | Consume an event stream (http://) |
client.receive() / isOpen() / close() | string / bool / void | Consume events |
Channel() | Channel | Create a channel |
ch.send(s) / receive() / receiveTimeout(ms) / isOpen() / close() | void / string / string / bool / void | Hand-off / long-poll |