back to the blog

Building Current (#1)

Current is a website (and very soon, a macOS/iOS app) that makes it really simple to send someone a file of any size. It is constructed out of Rust, TypeScript, and Swift, and builds on the fabulous work of the iroh team.

The Problem

I run a DJ collective, and part of doing that involves juggling tens of gigabytes of video files: recorded sets, promotional content, etc. Often times, some of the content will be recorded on a camera that is not owned by me, and we each take our own equipment home.

Then, the only practical ways to get these types of files are:

  1. Meet up in person and copy to my hard drive using a fancy USB-C cable
  2. Wait multiple hours while the files upload to cloud storage (that I am paying a subscription for), then download them

When I'm done editing a video, it's sometimes small enough to send to my teammates over iMessage so they can quickly take a look. But even then, iMessage still compresses the video aggressively, so I have to deliver videos to the person who will upload them using cloud storage again.

I can't let people upload this to Instagram.

All of this is inconvenient, slow, and requires me to pay for hundreds of gigabytes of cloud storage.

The Solution

For a while, I had been aware of a tool from n0-computer called sendme that kind of solves this problem. It can be used to send a file from one Internet-connected computer to nearly any other with zero configuration.

However, sendme is a terminal application. People who are not programmers (or similar) will not use this. And it won't work on a phone, which is where a lot of the files I want to move originate.

Current is a web/native interface for the iroh-blobs protocol that powers sendme. It consists of the following components:

  • current_core: A Rust crate that wraps iroh-blobs, tracks state for transfers, and exposes FFI bindings
  • @current/core: A Bun package which wraps current_core after it has been compiled to WebAssembly
  • @current/web: A TanStack Start website which uses @current/core to transfer files
  • Current-macOS, Current-iOS: A cross-platform Swift UI built over current_core (not released yet)

The Trials

This blog post is for those who are curious about how this software was made. Here, I will provide an overview of what I have done, and write more blog posts in the future with deep dives about specific things that I learned from each technical challenge.

1. Complex FFI across multiple targets

The FFI interface of current_core is not so simple. To provide a pleasant user experience, we need to support:

  • Detailed progress reporting: users need to know whether the transfer is working, and how long it's going to take
  • Cancellation: users might change their mind about allowing a transfer to finish

Additionally, current_core needs to compile to both WebAssembly and to aarch64, and we need somewhat different FFI conventions for these architectures.

To handle the task of creating a consistent FFI API across these platforms, I picked BoltFFI. It also promised better performance than wasm-bindgen, but I was mainly concerned with trying to dodge the iteration and maintenance burden of developing two separate FFI APIs, along with their corresponding bindings in TypeScript, Swift, and other languages.

The API that I landed on looks something like this:

pub struct Transfer {
    // these fields are public in Rust, but Transfer is opaque to foreign code
    id: TransferId,
    cancel: CancellationToken,
    callbacks: Arc<dyn TransferCallbacks>,
    // ...
}

impl Transfer {
    pub fn id(&self) -> TransferId;
    pub fn cancel(&self);
    pub fn is_cancelled(&self) -> bool;
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, derive_more::Display)]
pub struct TransferId {
    // Newtypes aren't easy to represent 1:1 in other languages, so I just ended up
    // using a single-member struct
    pub inner: u64,
}

#[derive(Clone)]
pub struct Sender {
    // ...
}

impl Sender {
    pub async fn new(endpoint: Endpoint) -> Result<Self, TransferError>;

    /// import from one filesystem root (file or directory tree), build ticket, publish until `unpublish`
    #[cfg(not(target_arch = "wasm32"))]
    pub async fn publish_paths(
        &self,
        paths: Vec<String>,
        sender_callbacks: Arc<dyn SenderCallbacks>,
        cancel: Option<CancelToken>,
    ) -> Result<Ticket, TransferError>;

    /// import from a host-driven stream source (e.g. wasm file inputs), then publish like `publish_paths`
    pub async fn publish_streams(
        &self,
        source: Box<dyn StreamSource>,
        sender_callbacks: Arc<dyn SenderCallbacks>,
        cancel: Option<CancelToken>,
    ) -> Result<Ticket, TransferError>;

    /// stop serving: reject new blob connections until next publish; closes active QUIC connections
    pub fn unpublish(&self);

    /// router shutdown + blob dir cleanup; call before `Endpoint::close` on app exit
    pub async fn shutdown(&self) -> Result<(), TransferError>;

    /// last successfully published ticket, if any
    pub fn ticket(&self) -> Option<Ticket>;

    /// close one inbound QUIC connection for this sender (outbound transfer cancel)
    pub fn cancel_connection(&self, connection_id: u64);
}

pub struct Receiver {
    // ...
}

impl Receiver {
    pub fn new(endpoint: Endpoint) -> Self;

    #[cfg(not(target_arch = "wasm32"))]
    pub fn receive(
        &self,
        ticket: Ticket,
        output_dir: String,
        callbacks: Arc<dyn TransferCallbacks>,
    ) -> Result<Transfer, TransferError>;

    #[cfg(target_arch = "wasm32")]
    pub fn receive(
        &self,
        ticket: Ticket,
        callbacks: Arc<dyn TransferCallbacks>,
    ) -> Result<Transfer, TransferError>;
}

pub struct Endpoint {
    pub(crate) inner: IrohEndpoint,
}

impl Endpoint {
    pub async fn new(config: TransferConfig) -> Result<Self, TransferError>;
}

/// implement in swift/ts; invoked from the tokio thread during transfers.
pub trait TransferCallbacks {
    fn on_state(&self, transfer_id: TransferId, state: TransferState);

    /// wasm: delivers the lazy [`ReceivedTransfer`] handle after a successful fetch.
    /// native implementations may leave this as a no-op (disk export is driven by `run_receive`).
    fn on_transfer_complete(&self, transfer_id: TransferId, data: ReceivedTransfer);
}

pub trait SenderCallbacks {
    fn on_state(&self, state: SenderState);

    /// A client has requested to receive the file.
    /// Returns a [`TransferCallbacks`] implementation to receive progress updates.
    fn on_transfer_starting(&self, id: TransferId) -> Box<dyn TransferCallbacks>;

    /// The transfer has started. Receives a [`Transfer`] struct with the transfer ID and cancellation token.
    fn on_transfer_started(&self, transfer: Transfer);
}

This contains a ton of complicated stuff, including:

  • Trait objects implemented in foreign code (TransferCallbacks and SenderCallbacks)
  • Async methods (Sender::new, Endpoint::new, Sender::publish_*)
  • Conditional compilation (WASM has no filesystem, so any code calling file system APIs must be conditionally compiled)

It turned out that BoltFFI was not quite ready yet to represent the API that I had in mind, but it was a great foundation. I ended up forking BoltFFI and customizing it with the help of LLM agents. These customizations include:

  • C-unwind ABI support in WASM.

    This is required to allow WASM exception handling. By default, any panic inside of a WASM module will abort and leave the module in an unusable state. Crucially, these types of errors can be difficult to catch if they occur in async code, and often just end up at the top-level unhandledrejection handler, where it becomes difficult to tell programmatically where the error came from.

  • Allowing conditional compilation based on target.

    BoltFFI contains a scanner, which parses source files directly, as well as derive macros, which generate internal FFI machinery. The scanner was not paying attention to #[cfg] attributes, leading to situations where bindgen paths that depend on the scanner would generate code that expects an item to be there, but it is missing under the current configuration.

  • Compatibility with wasm-bindgen.

    BoltFFI provides an interface for user JS code to interact with Rust code, but wasm-bindgen is still needed for Rust code to interact with browser APIs (ex.: Date.now()).

  • Async constructors and boxed futures.

    Async constructors are now supported. Methods returning Box<dyn Future> are now treated the same as async fn by BoltFFI.

  • Return Option<T> where T is a "class".

    Rust structs can be treated by BoltFFI as a "record" (fields are public, structure is converted into foreign representation when crossing FFI boundary) or as a "class" (object is opaque, foreign code can only call methods to it using a pointer). BoltFFI did not support returning nullable classes from exported methods.

  • Stricter scanning.

    For unsupported constructs (such as async constructors), BoltFFI was simply omitting them from the generated API instead of producing an error indicating that they were unsupported.

  • Swift 6 support.

    Swift 6 introduces ideas like Sendable which are similar to Rust's Send. It requires new annotations.

In the end, I traded one maintenance burden for another, but my hope is to eventually merge my changes to BoltFFI upstream. If you are on the BoltFFI team, please reach out to me if you're interested in getting this done.

2. Moving huge files without huge memory

It's very important that the browser client for Current is actually able to move big files. iroh-blobs does compile on WASM, but it loads the entire file into memory by default. Even though phones have 8-12GB of RAM these days, and computers even more, this will not do.

iroh-blobs works by breaking the file into chunks using another crate called bao-tree. bao-tree arranges the chunks into a tree based on their hash, which enables some useful properties:

  • Multiple chunks can be streamed in parallel, allowing users to saturate their network connections
  • Random access is allowed, so a client that already has part of the file can request just the parts they are missing
  • Verification is built-in

With the native clients, the file can stay where it is on the disk. iroh-blobs only needs to import the file, but this can be done without copying it. Importing the file is the process of chunking, hashing, and indexing the file. This index is called the outboard; it maps the hash of a pair of nodes (which represent adjacent portions of the file) to the hash of their parent node.

To avoid loading the entire file into memory in the browser, I created a new iroh-blobs store which stores the chunks generated by bao-tree in IndexedDB. I forked iroh-blobs to get access to the APIs needed to create a new store implementation.

Here's what the IndexedDB store looks like with some file data in it.

I then stream the file into (when importing before sending) and out of (when downloading after receiving) the IndexedDB store instead of loading it into a Blob to preserve our low memory usage.

Some footguns

At first, when I tested this with a 40 GB folder and ran a memory profile, it didn't seem to be working - memory usage was steadily climbing as the files were imported. I noticed that most of the allocations seemed to be coming from the performance API. I had added tracing instrumentation to Current to help me measure performance and troubleshoot issues, and I included tracing_web's PerformanceLayer. This resulted in millions of tracing spans being converted into performance.mark() spans, which were slowing down the browser and taking up memory. I solved this by disabling this layer in release builds.

let filter = build_filter();
let fmt_layer = tracing_subscriber::fmt::layer()
    .with_span_events(FmtSpan::NEW | FmtSpan::CLOSE)
    .with_ansi(browser_uses_ansi())
    .without_time()
    .with_writer(MakeWebConsoleWriter::new())
    .with_filter(filter.clone());

let registry = Registry::default().with(fmt_layer);

#[cfg(debug_assertions)]
let registry =
    registry.with(tracing_web::performance_layer().with_filter(filter.clone()));

registry.init();

Then, the 40 GB transfer failed because of an integer overflow issue inside of bao-tree:

/Users/ibiyemi/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/bao-tree-0.16.0/src/iter.rs:557:67:
called `Result::unwrap()` on an `Err` value: TryFromIntError(())

Stack:

Error
    ...
    at current_core.wasm.std::panicking::panic_handler::{{closure}}::hf36efc37fdd11196 (wasm://wasm/current_core.wasm-2253193e:wasm-function[26981]:0xc67764)
    at current_core.wasm.std::sys::backtrace::__rust_end_short_backtrace::h46ab1174c51ef229 (wasm://wasm/current_core.wasm-2253193e:wasm-function[72093]:0xeeb2c7)
    at current_core.wasm.__rustc[16f1505adc47261a]::rust_begin_unwind (wasm://wasm/current_core.wasm-2253193e:wasm-function[54848]:0xe5fe20)
    at current_core.wasm.core::panicking::panic_fmt::h6651313c3e2c6c2f (wasm://wasm/current_core.wasm-2253193e:wasm-function[44332]:0xdd8cf4)
    at current_core.wasm.core::result::unwrap_failed::h8a0dea2fe721e8ce (wasm://wasm/current_core.wasm-2253193e:wasm-function[32132]:0xcf2ab3)
    at current_core.wasm.<bao_tree::iter::PreOrderPartialChunkIterRef as core::iter::traits::iterator::Iterator>::next::hbc7a81cf3c3a9570 (wasm://wasm/current_core.wasm-2253193e:wasm-function[752]:0x32ddf1)
    at current_core.wasm.<bao_tree::iter::ResponseIterRef as core::iter::traits::iterator::Iterator>::next::h5ef02d4c8c4ffb39 (wasm://wasm/current_core.wasm-2253193e:wasm-function[10207]:0x94cda5)
    at current_core.wasm.bao_tree::iter::ResponseIterInner::next::{{closure}}::h3295e9b891109291 (wasm://wasm/current_core.wasm-2253193e:wasm-function[54168]:0xe58802)
    at current_core.wasm.bao_tree::iter::ResponseIterInner::with_dependent_mut::h9176d47f0444366b (wasm://wasm/current_core.wasm-2253193e:wasm-function[37313]:0xd62146)
    at current_core.wasm.bao_tree::iter::ResponseIterInner::next::h97e5e3955f049d04 (wasm://wasm/current_core.wasm-2253193e:wasm-function[60464]:0xe98b10)
    at current_core.wasm.<bao_tree::iter::ResponseIter as core::iter::traits::iterator::Iterator>::next::hd20a744bd30df1ed (wasm://wasm/current_core.wasm-2253193e:wasm-function[60463]:0xe98ae9)
    at current_core.wasm.bao_tree::io::fsm::ResponseDecoder<R>::next::{{closure}}::h8bc3491899fee20b (wasm://wasm/current_core.wasm-2253193e:wasm-function[2021]:0x506193)
    at current_core.wasm.iroh_blobs::get::fsm::AtBlobContent<R>::next::{{closure}}::h56ba7809b93b67d6 (wasm://wasm/current_core.wasm-2253193e:wasm-function[1371]:0x43c05f)
    at current_core.wasm.iroh_blobs::get::fsm::AtBlobContent<R>::drain::{{closure}}::h58d593c8031bc53f (wasm://wasm/current_core.wasm-2253193e:wasm-function[789]:0x341bb1)
    at current_core.wasm.iroh_blobs::get::request::get_hash_seq_and_sizes::{{closure}}::hb86ae1b9839d7bac (wasm://wasm/current_core.wasm-2253193e:wasm-function[198]:0xbe6cd)
    at current_core.wasm.current_core::receiver::run_receive_wasm::{{closure}}::{{closure}}::{{closure}}::hfef7bb8bad8f2a24 (wasm://wasm/current_core.wasm-2253193e:wasm-function[191]:0x90d6d)
    ...

In some places, usize was being used when a u64 was needed instead - in particular, for measuring the total size of the file. I fixed this in a fork. I think src/iter.rs:557 is the actual offending line, but the patch includes a bunch of other defensive changes as well.

Open source?

Maybe, but not yet. All of my forks are public so that others may use the improvements I've made in their own projects.

Since this project is more or less an unofficial GUI implementation of sendme, that means that we are also using n0's public relay servers. They are fabulous, but they also throttle your transfer speed to 3 MB/s after sustained use. I plan to offer access to dedicated (fast) relay servers for a small subscription. I will work out the monetization aspect before considering open source.

Why do we need relay servers?

For the native clients, you often don't. Web browsers, however, don't provide an API for establishing QUIC connections, so a relay is necessary.

Performance

I keep claiming that Current is fast. How fast is it really?

This is not a rigorous benchmark, but instead some simple test results.

  • 500 MB macOS -> web (public relay server): 102s
  • 500 MB macOS -> macOS (no relay server, local network): 62s
  • 500 MB web -> macOS (public relay server): 70s
  • 500 MB web -> web (public relay server): 75s

Why is it slower when sending from the macOS client to the web client? I don't know yet, but this seems to be a consistent result. I'll provide benchmarks and an investigation in a future update.

The end

Thank you for reading. Give Current a try!