//! OPFS (Origin Private File System) backend for browser wasm. use crate::{AsyncContainer, ByteHolder, ContainerError, Result, validate_path, validate_prefix, with_trailing_slash}; use futures::channel::oneshot; use js_sys::Uint8Array; use std::collections::{HashSet, VecDeque}; use std::sync::{Arc, Mutex}; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; use wasm_bindgen_futures::{JsFuture, spawn_local}; use web_sys::{ Blob, DomException, FileSystemCreateWritableOptions, FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetDirectoryOptions, FileSystemGetFileOptions, FileSystemWritableFileStream, WritableStream, }; enum Mutation { Write { path: String, bytes: Vec, }, Append { path: String, bytes: Vec, }, Delete { path: String, }, /// In-band flush barrier. The worker fulfills the sender once it dequeues this, which (by FIFO /// order) signals that every mutation enqueued before it has been applied to disk. Barrier(oneshot::Sender<()>), } struct Inner { directory: FileSystemDirectoryHandle, /// Paths believed to be on disk, letting `exists_non_blocking` answer without OPFS's missing sync API. /// Optimistic: a path is inserted when its write is queued and kept even if that write later fails, so it /// can briefly over-report. Rebuilt from real disk state on the next `open`. on_disk: HashSet, queue: VecDeque, worker_active: bool, } pub struct OpfsBackend { inner: Arc>, } // Safety: only built for browser wasm where JS handles never leave the main thread. unsafe impl Send for OpfsBackend {} unsafe impl Sync for OpfsBackend {} impl OpfsBackend { /// Open (or create) `directory_name` under the OPFS root. pub async fn open(directory_name: &str) -> Result { let directory = open_directory(directory_name).await.map_err(js_err)?; let on_disk = enumerate_paths(&directory, "").await.map_err(js_err)?; Ok(Self { inner: Arc::new(Mutex::new(Inner { directory, on_disk, queue: VecDeque::new(), worker_active: false, })), }) } fn directory(&self) -> FileSystemDirectoryHandle { self.inner.lock().unwrap().directory.clone() } /// Wait until all queued non-blocking mutations have hit disk, so awaited reads observe them. Plants a /// barrier at the back of the queue and awaits the worker reaching it; by FIFO order, everything ahead is /// then applied. Draining stays the worker's job so it remains the sole mutator. async fn flush(&self) { let (sender, receiver) = oneshot::channel(); { let mut guard = self.inner.lock().unwrap(); guard.queue.push_back(Mutation::Barrier(sender)); kick_worker(&self.inner, &mut guard); } // The sender is only dropped without sending if the worker is torn down mid-drain; either way // there is nothing left to wait for, so a receive error is treated as "already flushed". let _ = receiver.await; } } impl AsyncContainer for OpfsBackend { async fn read(&self, path: &str) -> Result { validate_path(path)?; // Non-blocking writes only land on disk once the queue drains, so flush first and then treat // disk as authoritative. Draining (rather than folding the queue against an awaited base read) // avoids racing the background worker, which could otherwise double-apply a queued append. self.flush().await; let bytes = read_file(&self.directory(), path).await.map_err(js_err)?; Ok(ByteHolder::Owned(bytes)) } async fn write(&self, path: &str, bytes: &[u8]) -> Result<()> { validate_path(path)?; // Flush first so this awaited write lands after any non-blocking mutation already queued for the // same path, then apply directly so the real disk error still propagates to the caller. self.flush().await; write_file(&self.directory(), path, bytes).await.map_err(js_err)?; self.inner.lock().unwrap().on_disk.insert(path.to_string()); Ok(()) } async fn append(&self, path: &str, bytes: &[u8]) -> Result<()> { validate_path(path)?; self.flush().await; append_file(&self.directory(), path, bytes).await.map_err(js_err)?; self.inner.lock().unwrap().on_disk.insert(path.to_string()); Ok(()) } async fn write_sized(&self, path: &str, size: usize, fill: &mut dyn FnMut(&mut [u8]) -> Result<()>) -> Result<()> { validate_path(path)?; let mut buffer = vec![0; size]; fill(&mut buffer)?; self.flush().await; write_file(&self.directory(), path, &buffer).await.map_err(js_err)?; self.inner.lock().unwrap().on_disk.insert(path.to_string()); Ok(()) } async fn list(&self, prefix: &str) -> Result> { validate_prefix(prefix)?; self.flush().await; list_entries(&self.directory(), prefix, EntryKind::File).await.map_err(|error| list_error(prefix, error)) } async fn list_dirs(&self, prefix: &str) -> Result> { validate_prefix(prefix)?; self.flush().await; list_entries(&self.directory(), prefix, EntryKind::Directory).await.map_err(|error| list_error(prefix, error)) } async fn exists(&self, path: &str) -> bool { if validate_path(path).is_err() { return false; } self.flush().await; file_exists(&self.directory(), path).await } async fn remove(&self, path: &str) -> Result<()> { validate_path(path)?; self.flush().await; // Idempotent: a missing entry is not an error, matching the other backends. if let Err(error) = remove_file(&self.directory(), path).await && !is_not_found(&error) { return Err(js_err(error)); } self.inner.lock().unwrap().on_disk.remove(path); Ok(()) } fn write_non_blocking(&self, path: &str, bytes: &[u8]) -> Result<()> { validate_path(path)?; let mut guard = self.inner.lock().unwrap(); guard.on_disk.insert(path.to_string()); guard.queue.push_back(Mutation::Write { path: path.to_string(), bytes: bytes.to_vec(), }); kick_worker(&self.inner, &mut guard); Ok(()) } fn append_non_blocking(&self, path: &str, bytes: &[u8]) -> Result<()> { validate_path(path)?; let mut guard = self.inner.lock().unwrap(); guard.on_disk.insert(path.to_string()); guard.queue.push_back(Mutation::Append { path: path.to_string(), bytes: bytes.to_vec(), }); kick_worker(&self.inner, &mut guard); Ok(()) } fn remove_non_blocking(&self, path: &str) -> Result<()> { validate_path(path)?; let mut guard = self.inner.lock().unwrap(); guard.on_disk.remove(path); guard.queue.push_back(Mutation::Delete { path: path.to_string() }); kick_worker(&self.inner, &mut guard); Ok(()) } fn exists_non_blocking(&self, path: &str) -> bool { validate_path(path).is_ok() && self.inner.lock().unwrap().on_disk.contains(path) } } fn kick_worker(inner: &Arc>, guard: &mut Inner) { if guard.worker_active { return; } guard.worker_active = true; let inner = inner.clone(); spawn_local(drain_queue(inner)); } /// Apply every queued mutation to disk, in FIFO order, until the queue is empty, then mark the worker /// idle. Spawned once via [`kick_worker`] and runs as the sole mutator; the awaited read paths wait on /// a [`Mutation::Barrier`] rather than draining themselves, so there is never more than one drainer. /// /// Consecutive appends to the same path are coalesced into one combined append. Each `createWritable` /// on OPFS copies the whole existing file, so appending N frames one at a time is O(N^2) in bytes /// copied (every per-op autosave queues one append per hot op). Concatenating a run into a single /// append collapses that to one file copy, while staying byte-identical to applying them in order. async fn drain_queue(inner: Arc>) { loop { // Pop the front mutation, coalescing a run of same-path appends behind it into one batch. let (directory, mutation) = { let mut guard = inner.lock().unwrap(); let Some(mut mutation) = guard.queue.pop_front() else { guard.worker_active = false; return; }; if let Mutation::Append { path, bytes } = &mut mutation { while let Some(Mutation::Append { path: next_path, .. }) = guard.queue.front() && next_path == path { let Some(Mutation::Append { bytes: next_bytes, .. }) = guard.queue.pop_front() else { unreachable!() }; bytes.extend_from_slice(&next_bytes); } } (guard.directory.clone(), mutation) }; match mutation { Mutation::Write { path, bytes } => { if let Err(error) = write_file(&directory, &path, &bytes).await { log::error!("OPFS background write for {path} failed: {error:?}"); } } Mutation::Append { path, bytes } => { if let Err(error) = append_file(&directory, &path, &bytes).await { log::error!("OPFS background append for {path} failed: {error:?}"); } } Mutation::Delete { path } => { // Removal is idempotent, so a missing entry is the expected outcome of a redundant delete, not an error. if let Err(error) = remove_file(&directory, &path).await && !is_not_found(&error) { log::error!("OPFS background delete for {path} failed: {error:?}"); } } // A receive error on the waiter side just means the reader gave up; nothing to apply. Mutation::Barrier(sender) => { let _ = sender.send(()); } } } } fn js_err(error: JsValue) -> ContainerError { ContainerError::Backend(format!("{error:?}")) } /// Resolve `directory_path` (a `/`-separated relative path) under the OPFS root, creating each /// segment. OPFS rejects directory names containing `/`, so a multi-segment path like /// `documents/` must be descended one segment at a time rather than passed whole. async fn open_directory(directory_path: &str) -> std::result::Result { let storage = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?.navigator().storage(); let mut current: FileSystemDirectoryHandle = JsFuture::from(storage.get_directory()).await?.dyn_into()?; for segment in directory_path.split('/').filter(|segment| !segment.is_empty()) { let options = FileSystemGetDirectoryOptions::new(); options.set_create(true); current = JsFuture::from(current.get_directory_handle_with_options(segment, &options)).await?.dyn_into()?; } Ok(current) } /// Descend the `/`-separated path against `root` and return the directory handle plus the final segment. async fn descend<'a>(root: &FileSystemDirectoryHandle, relative: &'a str, create_dirs: bool) -> std::result::Result<(FileSystemDirectoryHandle, &'a str), JsValue> { let mut current = root.clone(); let mut segments = relative.split('/').filter(|s| !s.is_empty()).collect::>(); let file = segments.pop().ok_or_else(|| JsValue::from_str("empty path"))?; for segment in segments { let options = FileSystemGetDirectoryOptions::new(); options.set_create(create_dirs); current = JsFuture::from(current.get_directory_handle_with_options(segment, &options)).await?.dyn_into()?; } Ok((current, file)) } async fn read_file(root: &FileSystemDirectoryHandle, path: &str) -> std::result::Result, JsValue> { let (directory, name) = descend(root, path, false).await?; let handle: FileSystemFileHandle = JsFuture::from(directory.get_file_handle(name)).await?.dyn_into()?; let file_value = JsFuture::from(handle.get_file()).await?; let blob: Blob = file_value.dyn_into()?; let buffer = JsFuture::from(blob.array_buffer()).await?; Ok(Uint8Array::new(&buffer).to_vec()) } async fn write_file(root: &FileSystemDirectoryHandle, path: &str, bytes: &[u8]) -> std::result::Result<(), JsValue> { let (directory, name) = descend(root, path, true).await?; let options = FileSystemGetFileOptions::new(); options.set_create(true); let handle: FileSystemFileHandle = JsFuture::from(directory.get_file_handle_with_options(name, &options)).await?.dyn_into()?; let writable: FileSystemWritableFileStream = JsFuture::from(handle.create_writable()).await?.dyn_into()?; let stream: WritableStream = writable.clone().unchecked_into(); // Wrap in an async block so any failure, including a synchronous JS throw from `write_with_js_u8_array`, // aborts the stream before returning instead of leaving a dangling locked writable. let write = async { let array = Uint8Array::from(bytes); JsFuture::from(writable.write_with_js_u8_array(&array)?).await?; Ok::<(), JsValue>(()) } .await; if let Err(error) = write { let _ = JsFuture::from(stream.abort()).await; return Err(error); } JsFuture::from(stream.close()).await?; Ok(()) } async fn append_file(root: &FileSystemDirectoryHandle, path: &str, bytes: &[u8]) -> std::result::Result<(), JsValue> { let (directory, name) = descend(root, path, true).await?; let file_options = FileSystemGetFileOptions::new(); file_options.set_create(true); let handle: FileSystemFileHandle = JsFuture::from(directory.get_file_handle_with_options(name, &file_options)).await?.dyn_into()?; // Determine the current end-of-file so we can seek there before writing. let file_value = JsFuture::from(handle.get_file()).await?; let blob: Blob = file_value.dyn_into()?; let offset = blob.size(); // `keepExistingData: true` preserves bytes outside the written range; without it OPFS truncates to the written length. let writable_options = FileSystemCreateWritableOptions::new(); writable_options.set_keep_existing_data(true); let writable: FileSystemWritableFileStream = JsFuture::from(handle.create_writable_with_options(&writable_options)).await?.dyn_into()?; let stream: WritableStream = writable.clone().unchecked_into(); // Wrap in an async block so a synchronous JS throw from `seek_with_f64`/`write_with_js_u8_array` // aborts the stream instead of bypassing the abort via `?`. let seek_and_write = async { JsFuture::from(writable.seek_with_f64(offset)?).await?; let array = Uint8Array::from(bytes); JsFuture::from(writable.write_with_js_u8_array(&array)?).await?; Ok::<(), JsValue>(()) } .await; if let Err(error) = seek_and_write { let _ = JsFuture::from(stream.abort()).await; return Err(error); } JsFuture::from(stream.close()).await?; Ok(()) } async fn remove_file(root: &FileSystemDirectoryHandle, path: &str) -> std::result::Result<(), JsValue> { let (directory, name) = descend(root, path, false).await?; JsFuture::from(directory.remove_entry(name)).await?; Ok(()) } async fn file_exists(root: &FileSystemDirectoryHandle, path: &str) -> bool { let Ok((directory, name)) = descend(root, path, false).await else { return false; }; JsFuture::from(directory.get_file_handle(name)).await.is_ok() } #[derive(Clone, Copy)] enum EntryKind { File, Directory, } async fn list_entries(root: &FileSystemDirectoryHandle, prefix: &str, want: EntryKind) -> std::result::Result, JsValue> { // `.` and the empty string both name the container root. let prefix = if prefix == "." { "" } else { prefix }; let directory = if prefix.is_empty() { root.clone() } else { let mut current = root.clone(); for segment in prefix.split('/').filter(|s| !s.is_empty()) { let options = FileSystemGetDirectoryOptions::new(); options.set_create(false); current = match JsFuture::from(current.get_directory_handle_with_options(segment, &options)).await { Ok(value) => value.dyn_into()?, Err(error) if is_not_found(&error) => return Ok(Vec::new()), Err(error) => return Err(error), }; } current }; let entries = directory.entries(); let iterator: js_sys::AsyncIterator = entries.unchecked_into(); let mut results = Vec::new(); let prefix_with_slash = with_trailing_slash(prefix); let want_kind = match want { EntryKind::File => web_sys::FileSystemHandleKind::File, EntryKind::Directory => web_sys::FileSystemHandleKind::Directory, }; loop { let next: js_sys::IteratorNext = JsFuture::from(iterator.next()?).await?.unchecked_into(); if next.done() { break; } let pair: js_sys::Array = next.value().unchecked_into(); let Some(name) = pair.get(0).as_string() else { continue }; let handle: web_sys::FileSystemHandle = pair.get(1).unchecked_into(); if handle.kind() != want_kind { continue; } results.push(format!("{prefix_with_slash}{name}")); } Ok(results) } /// Walk every file under `prefix` (recursively) and collect their full container paths. /// Used at open time to populate the in-memory tracking set. async fn enumerate_paths(root: &FileSystemDirectoryHandle, prefix: &str) -> std::result::Result, JsValue> { let mut paths = HashSet::new(); let mut to_visit = vec![prefix.to_string()]; while let Some(current_prefix) = to_visit.pop() { for file in list_entries(root, ¤t_prefix, EntryKind::File).await? { paths.insert(file); } for dir in list_entries(root, ¤t_prefix, EntryKind::Directory).await? { to_visit.push(dir); } } Ok(paths) } fn is_not_found(error: &JsValue) -> bool { error.dyn_ref::().is_some_and(|error| error.name() == "NotFoundError") } /// OPFS raises `TypeMismatchError` when a path segment used as a directory is actually a file. fn is_type_mismatch(error: &JsValue) -> bool { error.dyn_ref::().is_some_and(|error| error.name() == "TypeMismatchError") } /// Map a listing error: a prefix that names a file becomes [`ContainerError::NotADirectory`], anything /// else passes through as a backend error. fn list_error(prefix: &str, error: JsValue) -> ContainerError { if is_type_mismatch(&error) { ContainerError::NotADirectory(prefix.to_string()) } else { js_err(error) } }