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"
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
);
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);
});
});
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();
});
And from the browser:
socket.emit("join_room", { room: "uploads" });
socket.on("joined", (data) => console.log("In room:", data.room));
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();
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),
}
}
}
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?;
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
}
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
libunftpat exactly0.20.0. Newer versions pull in aclang-sysdependency 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?;
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!()
}
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
// 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(())
}
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`);
});
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
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)