Real-time communication on the web has always been an afterthought. You build your HTTP application, then you realize you need live updates, so you bolt on Socket.IO (2.4 MB of dependencies), configure a separate WebSocket server, manage connection state in Redis, and write event handlers that duplicate your HTTP route logic. The complexity doubles, the deployment surface doubles, and the bug surface doubles.
FLIN treats WebSockets as a first-class feature of the language runtime. The same embedded HTTP server that handles regular requests also handles WebSocket upgrades. You define WebSocket endpoints with ws route blocks, and they coexist with regular route blocks in the same file. No additional dependency. No separate server. No configuration.
The ws Route Block
WebSocket endpoints are defined with the ws keyword instead of route:
// app/api/chat.flinguard auth
ws { on connect { log_info("User connected: {session.user}") broadcast("system", "{session.userName} joined the chat") }
on message(data) { save ChatMessage { user: session.user, content: data.text, room: data.room || "general" }
broadcast(data.room || "general", { user: session.userName, text: data.text, time: now() }) }
on close { log_info("User disconnected: {session.user}") broadcast("system", "{session.userName} left the chat") } } ```
Three event handlers: connect, message, and close. The data parameter in the message handler is automatically parsed from JSON. The broadcast function sends a message to all connected clients in a room. Guards apply to WebSocket connections just like they apply to HTTP routes -- the auth guard ensures only authenticated users can open a WebSocket connection.
WebSocket + HTTP in One File
The real power emerges when WebSocket and HTTP routes coexist:
// app/api/notifications.flinguard auth
// REST endpoint to create a notification route POST { validate { user_id: int @required message: text @required }
notification = Notification { user_id: body.user_id, message: body.message, read: false } save notification
// Push to connected WebSocket clients ws_send(to_text(body.user_id), { type: "notification", data: notification })
response { status: 201, body: notification } }
// REST endpoint to get notifications route GET { Notification.where(user_id == to_int(session.userId) && read == false) .order(created_at, "desc") .limit(50) }
// WebSocket for real-time notifications ws { on connect { ws_join(session.userId) // Join room by user ID }
on message(data) { if data.type == "mark_read" { notification = Notification.find(data.id) if notification != none && notification.user_id == to_int(session.userId) { notification.read = true save notification } } } } ```
A single file handles creating notifications (POST), fetching unread notifications (GET), streaming new notifications in real time (WebSocket), and marking notifications as read (WebSocket message). The traditional approach would spread this across at least three files and two servers.
Connection Management
The FLIN runtime manages WebSocket connections internally. Each connection is identified by a unique ID and can be associated with rooms (named groups):
// Room management
ws_join("room_name") // Current connection joins a room
ws_leave("room_name") // Current connection leaves a room// Sending messages ws_send(connection_id, data) // Send to specific connection broadcast(room, data) // Send to all connections in a room broadcast_all(data) // Send to ALL connected clients ```
The connection state is stored in the runtime's memory:
pub struct WebSocketManager {
connections: HashMap<String, WebSocketConnection>,
rooms: HashMap<String, HashSet<String>>,
}pub struct WebSocketConnection {
id: String,
sender: mpsc::Sender
When a client disconnects (gracefully or due to network failure), the runtime automatically removes the connection from all rooms and triggers the on close handler. There is no cleanup code for the developer to write.
The WebSocket Upgrade Process
WebSocket connections start as HTTP requests with an Upgrade: websocket header. The FLIN server handles this upgrade transparently:
async fn handle_request(
stream: TcpStream,
request: &Request,
router: &Router,
) -> Result<(), ServerError> {
// Check for WebSocket upgrade
if request.is_websocket_upgrade() {
let handler = router.find_ws_handler(&request.path)?;// Evaluate guards before upgrade evaluate_guards(&handler.guards, &request.context())?;
// Perform WebSocket handshake let ws_stream = accept_websocket(stream, request).await?;
// Spawn connection handler tokio::spawn(handle_websocket(ws_stream, handler, request.session.clone()));
return Ok(()); }
// Regular HTTP handling handle_http(stream, request, router).await } ```
Guards are evaluated BEFORE the WebSocket handshake completes. An unauthenticated client receives a 401 Unauthorized HTTP response and the connection is never upgraded. This is a critical security detail -- many WebSocket libraries perform the upgrade first and check authentication later, creating a window where unauthenticated clients have an open connection.
Binary Data and Streaming
FLIN WebSockets handle both text and binary messages:
// app/api/stream.flinws { on message(data) { if data.type == "binary" { // data.bytes contains the raw binary data file_path = save_binary(data.bytes, ".flindb/streams/") ws_send(ws.connection_id, { type: "ack", path: file_path, size: data.bytes.len }) } else { // Text message (default) process_text(data) } } } ```
Heartbeat and Connection Health
The runtime sends periodic ping frames to detect dead connections. If a client does not respond with a pong within 30 seconds, the connection is closed and the on close handler fires:
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(15);
const CLIENT_TIMEOUT: Duration = Duration::from_secs(30);async fn heartbeat_loop(ws: &mut WebSocketStream, last_pong: &mut Instant) { let mut interval = tokio::time::interval(HEARTBEAT_INTERVAL);
loop { interval.tick().await;
if last_pong.elapsed() > CLIENT_TIMEOUT { // Client is dead ws.close().await; return; }
ws.send(Message::Ping(vec![])).await; } } ```
This heartbeat mechanism is invisible to FLIN developers. Dead connections are cleaned up automatically, rooms are updated, and the on close handler runs with the same guarantee as if the client had disconnected gracefully.
Real-World Example: Collaborative Editing
Here is a more complete example showing collaborative document editing with WebSockets:
// app/api/documents/[id]/live.flinguard auth
ws { on connect { doc = Document.find(params.id) if doc == none { ws_close("Document not found") return }
ws_join("doc:" + params.id) broadcast("doc:" + params.id, { type: "user_joined", user: session.userName })
// Send current document state to new connection ws_send(ws.connection_id, { type: "init", content: doc.content, version: doc.version }) }
on message(data) { if data.type == "edit" { doc = Document.find(params.id) doc.content = apply_patch(doc.content, data.patch) save doc
broadcast("doc:" + params.id, { type: "edit", patch: data.patch, user: session.userName, version: doc.version }) }
if data.type == "cursor" { broadcast("doc:" + params.id, { type: "cursor", user: session.userName, position: data.position }) } }
on close { broadcast("doc:" + params.id, { type: "user_left", user: session.userName }) } } ```
Document state, user presence, cursor positions, and edit patches -- all in a single file, all using the same entity system, guards, and session management as the rest of the application.
Why Built-In WebSockets Matter
The traditional approach to adding WebSockets to a web application involves choosing a library (ws, Socket.IO, uWebSockets), configuring it alongside your HTTP server, managing a separate connection state store, handling reconnection logic, and maintaining two parallel communication channels that must stay in sync.
FLIN eliminates all of this. WebSockets are part of the same server, the same routing system, the same guard system, and the same session system as HTTP routes. A WebSocket handler can call User.find() just like an HTTP handler. It can read session.user just like a view template. It can use broadcast() to send messages without importing a library.
This integration is what "built into the language" actually means. Not a wrapper around a library. Not a plugin system. A fundamental capability that is as natural to use as an HTTP route.
In the next article, we cover file upload support -- how FLIN handles multipart uploads, file storage, and size validation without a single line of configuration.
---
This is Part 103 of the "How We Built FLIN" series, documenting how a CEO in Abidjan and an AI CTO designed and built a programming language from scratch.
Series Navigation: - [101] The Middleware System - [102] Guards: Declarative Security for Routes - [103] WebSocket Support Built Into the Language (you are here) - [104] File Upload Support