DEV Community

Cover image for Beyond HTTP in Rust: Real-Time Sockets and FTP — Built From Scratch
Manan Shukla
Manan Shukla

Posted on

Beyond HTTP in Rust: Real-Time Sockets and FTP — Built From Scratch

HTTP is great. It handles the vast majority of what most services need. But there are two areas where it genuinely falls short, and if you've hit either of them you know exactly what I mean.

Real-time push. You want the browser to know something happened — immediately — without polling. Raw WebSockets work but you write all the framing, reconnection logic, and event routing yourself. Socket.IO is the standard that every JavaScript developer knows, but until recently Rust had no good server-side implementation.

File transfer over FTP. Legacy systems, media pipelines, deployment tooling — FTP is everywhere in enterprise and infrastructure. Rust's async ecosystem had a gap here for a long time.

This post fills both gaps. We'll build a Socket.IO server, an FTP server with upload-only restrictions, and an async FTP client — then tie them together into a system where a file uploaded over FTP instantly notifies every connected browser.

All code is on GitHub: rust-beyond-http. This post focuses on the key concepts and snippets — the repo has the full runnable project.


Project Setup

[dependencies]
tokio        = { version = "1", features = ["full"] }

# Socket.IO
socketioxide = { version = "0.18", features = ["extensions"] }
axum         = "0.8"
tower-http   = { version = "0.6", features = ["cors"] }

# FTP server — all from crates.io, no local paths needed
libunftp           = "0.23"
unftp-sbe-fs       = "0.3"
unftp-sbe-restrict = "0.1.2"

# FTP client (v7 uses "tokio" feature)
suppaftp = { version = "7", features = ["tokio"] }

serde      = { version = "1", features = ["derive"] }
serde_json = "1"
Enter fullscreen mode Exit fullscreen mode

The unftp-sbe-restrict is a small local crate included in the repo that wraps the FTP storage backend and blocks destructive operations. More on that shortly.


Part 1 — Socket.IO Server

Why Not Raw WebSockets?

Raw WebSockets give you a pipe. You get bytes in and bytes out — that's it. No named events, no rooms, no automatic reconnection, no fallback for environments where WebSockets are blocked. You build all of that yourself.

Socket.IO gives you all of it. And more importantly: every JavaScript frontend developer already knows how to use it. io("http://localhost:3000") and you're connected.

socketioxide is the Rust implementation that integrates as an axum layer. It speaks the Socket.IO protocol natively — any Socket.IO client (browser, Node.js, Python) connects to it without modification.

Building the Server

The server plugs in as an axum layer — your existing HTTP routes keep working alongside the WebSocket connections:

let (layer, io) = SocketIo::builder()
    .with_state(state.clone())
    .build_layer();

let app = Router::new()
    .route("/health", get(|| async { "ok" }))  // normal HTTP still works
    .layer(
        ServiceBuilder::new()
            .layer(CorsLayer::permissive())
            .layer(layer),  // Socket.IO plugs in here
    );
Enter fullscreen mode Exit fullscreen mode

Connection Lifecycle

Every time a browser connects, your handler fires:

io.ns("/", |socket: SocketRef, State(state): State<AppState>| async move {
    info!("Client connected: {}", socket.id);

    // Send a welcome event immediately on connect
    socket.emit("welcome", json!({ "id": socket.id.to_string() })).ok();

    // Handle disconnect
    socket.on_disconnect(|socket: SocketRef| async move {
        info!("Client disconnected: {}", socket.id);
    });
});
Enter fullscreen mode Exit fullscreen mode

socket.id is unique per connection. on_disconnect fires whether the client closed the tab, lost network, or called socket.disconnect() explicitly.

Event-Based Messaging

Named events are how Socket.IO communicates — not raw bytes, not HTTP verbs. The client emits "join_room", the server handles it:

#[derive(Deserialize)]
struct JoinRoom { room: String }

socket.on("join_room", |socket: SocketRef, Data::<JoinRoom>(data)| async move {
    socket.join(data.room.clone()).ok();
    socket.emit("joined", json!({ "room": data.room })).ok();
});
Enter fullscreen mode Exit fullscreen mode

And from the browser:

socket.emit("join_room", { room: "uploads" });
socket.on("joined", (data) => console.log("In room:", data.room));
Enter fullscreen mode Exit fullscreen mode

Rooms and Broadcasting

Rooms let you group connections and broadcast to subsets of clients. When a file is uploaded, you only notify clients who subscribed to that directory — not everyone:

// Broadcast to everyone in "uploads" room
io.to("uploads").emit("file_uploaded", &event).ok();

// Broadcast to everyone except the sender
socket.broadcast().emit("new_client", &count_event).ok();

// Send to everyone, including sender
io.emit("announcement", &msg).ok();
Enter fullscreen mode Exit fullscreen mode

Rooms are created implicitly when the first client joins — no registration required. They're destroyed automatically when the last client leaves.


Part 2 — FTP Server with Upload-Only Restrictions

Setting Up libunftp

libunftp builds an FTP server from composable pieces: a storage backend, an authenticator, and optional middleware. We're using the filesystem backend and a custom password authenticator:

// Custom authenticator — checks username and password
#[derive(Debug)]
struct PasswordAuth { username: String, password: String }

#[async_trait]
impl Authenticator<DefaultUser> for PasswordAuth {
    async fn authenticate(
        &self, username: &str, creds: &Credentials,
    ) -> Result<DefaultUser, AuthenticationError> {
        if username != self.username {
            return Err(AuthenticationError::BadUser);
        }
        match &creds.password {
            Some(pw) if pw.as_str() == self.password => Ok(DefaultUser),
            _ => Err(AuthenticationError::BadPassword),
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
let server = libunftp::Server::with_fs("/tmp/ftp-uploads")
    .authenticator(Arc::new(PasswordAuth {
        username: "uploader".into(),
        password: "secret123".into(),
    }))
    .build()?;

server.listen("0.0.0.0:2121").await?;
Enter fullscreen mode Exit fullscreen mode

Upload-Only Restrictions with unftp-sbe-restrict

The local unftp-sbe-restrict crate wraps any storage backend and intercepts destructive operations, returning PermissionDenied before they reach the filesystem:

// In unftp-sbe-restrict/src/lib.rs

async fn del<P: AsRef<Path> + Send + Debug>(
    &self, _user: &Option<U>, path: P,
) -> Result<()> {
    warn!("Blocked delete attempt on {:?}", path.as_ref());
    Err(libunftp::storage::Error::from(
        libunftp::storage::ErrorKind::PermissionDenied,
    ))
}

async fn rename<P: AsRef<Path> + Send + Debug>(
    &self, _user: &Option<U>, from: P, to: P,
) -> Result<()> {
    warn!("Blocked rename: {:?} -> {:?}", from.as_ref(), to.as_ref());
    Err(libunftp::storage::Error::from(
        libunftp::storage::ErrorKind::PermissionDenied,
    ))
}

// put() passes through to the inner backend — uploads are allowed
async fn put<P, R>(&self, user: &Option<U>, input: R, path: P, start_pos: u64) -> Result<u64> {
    self.inner.put(user, input, path, start_pos).await
}
Enter fullscreen mode Exit fullscreen mode

The full permission table after wrapping:

FTP Operation Allowed
Upload (PUT) ✅ Yes
Download (GET) ✅ Yes
List (LIST/MLSD) ✅ Yes
Delete (DELE) ❌ Blocked
Rename (RNFR/RNTO) ❌ Blocked
Make directory (MKD) ❌ Blocked
Remove directory (RMD) ❌ Blocked

⚠️ Version note: Keep libunftp at exactly 0.20.0. Newer versions pull in a clang-sys dependency that conflicts with the version already in the tree and breaks compilation. The repo pins all three crates to exact versions for this reason.


Part 3 — FTP Client

The async FTP client wraps the full workflow — connect, authenticate, operate, disconnect:

// Connect and authenticate
let mut ftp = AsyncFtpStream::connect("127.0.0.1:2121").await?;
ftp.login("uploader", "secret123").await?;

// List remote directory
let entries = ftp.list(None).await?;
for entry in &entries {
    println!("{}", entry);
}

// Upload a file
let data   = tokio::fs::read("report.pdf").await?;
let cursor = std::io::Cursor::new(&data);
ftp.put_file("report.pdf", &mut cursor).await?;

// Download a file
let mut reader = ftp.retr_as_stream("report.pdf").await?;
let mut buf    = Vec::new();
reader.read_to_end(&mut buf).await?;
ftp.finalize_retr_stream(reader).await?;  // must call this to close the data connection

// Disconnect cleanly
ftp.quit().await?;
Enter fullscreen mode Exit fullscreen mode

Retry Logic

Network hiccups happen. A simple exponential backoff wrapper covers most cases:

pub async fn upload_with_retry(
    host: &str, user: &str, pass: &str,
    filename: &str, data: &[u8],
    max_retries: u32,
) -> Result<(), FtpError> {
    for attempt in 1..=max_retries {
        let result = async {
            let mut ftp = AsyncFtpStream::connect(host).await?;
            ftp.login(user, pass).await?;
            ftp.put_file(filename, &mut std::io::Cursor::new(data)).await?;
            ftp.quit().await
        }.await;

        match result {
            Ok(()) => return Ok(()),
            Err(e) if attempt < max_retries => {
                eprintln!("Attempt {} failed: {} — retrying...", attempt, e);
                tokio::time::sleep(Duration::from_secs(2u64.pow(attempt))).await;
            }
            Err(e) => return Err(e),
        }
    }
    unreachable!()
}
Enter fullscreen mode Exit fullscreen mode

Part 4 — Tying It Together

The combined system is simple by design: a broadcast channel connects the FTP uploader to the Socket.IO notifier.

Browser ──── WebSocket ────► Socket.IO Server ◄──── broadcast::Receiver
                                                              │
FTP Client ──► FTP Server ──► upload complete ──► broadcast::Sender
Enter fullscreen mode Exit fullscreen mode
// Broadcast channel — FTP side sends, Socket.IO side receives
let (upload_tx, upload_rx) = broadcast::channel::<UploadEvent>(16);

// Socket.IO server listens for upload events and broadcasts to browsers
tokio::spawn(async move {
    loop {
        if let Ok(event) = upload_rx.recv().await {
            io.emit("file_uploaded", &event).ok();
        }
    }
});

// After a successful FTP upload, notify all connected browsers
async fn upload_and_notify(
    ftp_host: &str, filename: &str, data: &[u8],
    tx: broadcast::Sender<UploadEvent>,
) -> anyhow::Result<()> {
    let mut ftp = AsyncFtpStream::connect(ftp_host).await?;
    ftp.login("uploader", "secret123").await?;
    ftp.put_file(filename, &mut std::io::Cursor::new(data)).await?;
    ftp.quit().await?;

    // Fire the notification — Socket.IO server picks it up
    tx.send(UploadEvent {
        filename:    filename.to_string(),
        size_bytes:  data.len() as u64,
        uploaded_at: now_ms(),
    }).ok();

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The broadcast::channel is the right primitive here — not mpsc. Multiple future consumers (imagine per-directory notification channels) can all receive the same event independently, each getting their own copy. The capacity of 16 handles bursts of simultaneous uploads without blocking the FTP side.

On the browser, receiving the notification is as simple as:

const socket = io("http://localhost:3000");
socket.on("file_uploaded", (data) => {
    console.log(`${data.filename} uploaded — ${data.size_bytes} bytes`);
});
Enter fullscreen mode Exit fullscreen mode

Running the System

# 1. Start the FTP server
FTP_ROOT=/tmp/ftp-uploads FTP_USER=uploader FTP_PASS=secret123 \
  cargo run --bin ftp_server

# 2. In another terminal — run the combined system
#    (starts Socket.IO server + uploads a test file)
FTP_HOST=127.0.0.1:2121 FTP_USER=uploader FTP_PASS=secret123 \
  cargo run --bin combined

# 3. Open index.html in your browser
#    — watch the upload notification arrive in real time
Enter fullscreen mode Exit fullscreen mode

When to Use What

Tool Reach for it when
Socket.IO Real-time push to browsers. Event-driven comms. Rooms for targeted delivery.
FTP client Talking to existing FTP servers. Legacy systems, media pipelines, deployment tooling.
FTP server Building your own file ingestion endpoint. Full control over auth and permissions.
Combined Any system where a background operation (upload, processing, job completion) needs to notify a live UI immediately.

Wrapping Up

Raw WebSockets and hand-rolled FTP clients work — they've worked for decades. But the Rust ecosystem now has crates that handle the protocol complexity for you: Socket.IO semantics with rooms and events, async FTP with a clean API, and an FTP server you can compose and restrict exactly the way you need.

The file-upload notification system we built is deliberately simple. But the pattern — background operation triggers broadcast channel triggers Socket.IO emit — scales to anything: job completion notifications, live data pipeline updates, deployment status dashboards.

The full project is on GitHub: rust-beyond-http. Clone it, run it, and adapt it.


That wraps up the Rust Beyond HTTP series. We've gone from async fundamentals with Tokio, through shared state and serialization, into gRPC streaming, and now real-time sockets and FTP. If there's a specific topic you want covered next — drop it in the comments.

Top comments (0)