You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@opendal.apache.org by xu...@apache.org on 2026/05/11 09:42:26 UTC

(opendal) branch main updated: feat(services/opfs): OPFS backend for OpenDAL (#7199)

This is an automated email from the ASF dual-hosted git repository.

Xuanwo pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/opendal.git


The following commit(s) were added to refs/heads/main by this push:
     new c3eaa407a feat(services/opfs): OPFS backend for OpenDAL (#7199)
c3eaa407a is described below

commit c3eaa407aaafc37a139803229ec100a82991493f
Author: JC <jc...@icloud.com>
AuthorDate: Mon May 11 10:42:20 2026 +0100

    feat(services/opfs): OPFS backend for OpenDAL (#7199)
    
    * wip utils
    
    * wip remove redundant funcs
    
    * wip
    
    * wip error
    
    * wip remove unused code
    
    * Add OPFS for wasm32 backend edge test
    
    * create_dir test
    
    * wip write
    
    * wip write
    
    * uncomment test
    
    * Add console feature to web-sys dep in Cargo.toml
    
    * Add root in OpfsConfig
    
    * Add stat, fix test, fix root
    
    * revert vs code settings.json
    
    * Add reader
    
    * Use SendWrapper
    
    * wip list broken
    
    * Fix path in get_parent_dir_and_name
    
    * Use write_with_write_params (due to Safari)
    
    * add lister.rs file
    
    * delete
    
    * revert .vscode/settings.json
    
    * cargo fmt
    
    * trying fixing gh actions
    
    * Format core/services/opfs/Cargo.toml
    
    * Format core/edge/opfs_wasm32/Cargo.toml
    
    * update capabilities in docs.md
    
    * Remove debug logs
    
    * cargo fmt
    
    * fix(services/opfs): add missing send_wrapper entry in Cargo.lock
---
 core/Cargo.lock                   |  19 +++
 core/edge/opfs_wasm32/Cargo.toml  |  40 +++++++
 core/edge/opfs_wasm32/README.md   |  27 +++++
 core/edge/opfs_wasm32/src/lib.rs  | 238 ++++++++++++++++++++++++++++++++++++++
 core/services/opfs/Cargo.toml     |   9 +-
 core/services/opfs/src/backend.rs | 115 ++++++++++++++----
 core/services/opfs/src/config.rs  |   9 +-
 core/services/opfs/src/core.rs    |  72 +++++-------
 core/services/opfs/src/deleter.rs |  60 ++++++++++
 core/services/opfs/src/docs.md    |  12 +-
 core/services/opfs/src/error.rs   |  29 ++++-
 core/services/opfs/src/lib.rs     |  11 ++
 core/services/opfs/src/lister.rs  |  76 ++++++++++++
 core/services/opfs/src/reader.rs  |  75 ++++++++++++
 core/services/opfs/src/utils.rs   |  56 ++++++---
 core/services/opfs/src/writer.rs  |  80 +++++++++++++
 16 files changed, 838 insertions(+), 90 deletions(-)

diff --git a/core/Cargo.lock b/core/Cargo.lock
index 7340fe169..0d50500cd 100644
--- a/core/Cargo.lock
+++ b/core/Cargo.lock
@@ -2759,6 +2759,18 @@ dependencies = [
  "tokio",
 ]
 
+[[package]]
+name = "edge_test_opfs_wasm32"
+version = "0.56.0"
+dependencies = [
+ "futures",
+ "opendal",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-bindgen-test",
+ "web-sys",
+]
+
 [[package]]
 name = "edge_test_s3_read_on_wasm"
 version = "0.56.0"
@@ -7105,6 +7117,7 @@ version = "0.56.0"
 dependencies = [
  "js-sys",
  "opendal-core",
+ "send_wrapper",
  "serde",
  "wasm-bindgen",
  "wasm-bindgen-futures",
@@ -9788,6 +9801,12 @@ dependencies = [
  "serde_core",
 ]
 
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+
 [[package]]
 name = "serde"
 version = "1.0.228"
diff --git a/core/edge/opfs_wasm32/Cargo.toml b/core/edge/opfs_wasm32/Cargo.toml
new file mode 100644
index 000000000..cb6adcb04
--- /dev/null
+++ b/core/edge/opfs_wasm32/Cargo.toml
@@ -0,0 +1,40 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+[package]
+name = "edge_test_opfs_wasm32"
+
+edition.workspace = true
+license.workspace = true
+publish = false
+rust-version.workspace = true
+version.workspace = true
+
+[lib]
+crate-type = ["cdylib"]
+
+[dependencies]
+futures = { workspace = true, features = ["std", "async-await"] }
+opendal = { path = "../..", default-features = false, features = [
+  "services-opfs",
+] }
+wasm-bindgen = "0.2.89"
+wasm-bindgen-futures = "0.4.39"
+web-sys = { version = "0.3.77", features = ["console"] }
+
+[dev-dependencies]
+wasm-bindgen-test = "0.3.41"
diff --git a/core/edge/opfs_wasm32/README.md b/core/edge/opfs_wasm32/README.md
new file mode 100644
index 000000000..409c2f25d
--- /dev/null
+++ b/core/edge/opfs_wasm32/README.md
@@ -0,0 +1,27 @@
+# OPFS on WASM
+
+This test verifies the OpenDAL OPFS service works in a browser environment.
+
+## Install
+
+```shell
+cargo install wasm-pack
+```
+
+## Build
+
+```shell
+wasm-pack build
+```
+
+## Test
+
+NOTE:
+
+- You need to have Chrome installed.
+- OPFS requires a browser context (no Node.js support).
+- Headless Chrome may not work for OPFS tests.
+
+```shell
+wasm-pack test --chrome
+```
diff --git a/core/edge/opfs_wasm32/src/lib.rs b/core/edge/opfs_wasm32/src/lib.rs
new file mode 100644
index 000000000..b56c3f6d7
--- /dev/null
+++ b/core/edge/opfs_wasm32/src/lib.rs
@@ -0,0 +1,238 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#![cfg(target_arch = "wasm32")]
+#[cfg(test)]
+mod tests {
+    use futures::TryStreamExt;
+    use opendal::EntryMode;
+    use opendal::ErrorKind;
+    use opendal::Operator;
+    use opendal::services::Opfs;
+    use opendal::services::OpfsConfig;
+    use wasm_bindgen_test::wasm_bindgen_test;
+    use wasm_bindgen_test::wasm_bindgen_test_configure; // Required for Next()
+
+    macro_rules! console_log {
+        ($($arg:tt)*) => {
+            web_sys::console::log_1(&format!($($arg)*).into())
+        };
+    }
+
+    wasm_bindgen_test_configure!(run_in_browser);
+
+    fn new_operator() -> Operator {
+        Operator::from_config(OpfsConfig::default())
+            .expect("failed to create opfs operator")
+            .finish()
+    }
+
+    #[wasm_bindgen_test]
+    async fn test_create_directory_handle() {
+        let op = new_operator();
+        op.create_dir("/dir/").await.expect("directory");
+        op.create_dir("/dir///").await.expect("directory");
+        op.create_dir("/dir:/").await.expect("directory");
+        op.create_dir("/dir<>/").await.expect("directory");
+        assert_eq!(
+            op.create_dir("/a/b/../x/y/z/").await.unwrap_err().kind(),
+            ErrorKind::Unexpected
+        );
+        // this works on Chrome, but fails on macOS
+        // assert_eq!(op.create_dir("/dir\0/").await.unwrap_err().kind(), ErrorKind::Unexpected);
+    }
+
+    #[wasm_bindgen_test]
+    async fn test_create_directory_handle_with_root_and_list() {
+        {
+            // create files and dirs
+            let op_rooted = Operator::new(Opfs::default().root("/myapp/subdir1/subdir2/"))
+                .expect("config")
+                .finish();
+            op_rooted
+                .write("subdir3/somefile", "content")
+                .await
+                .expect("write under root");
+
+            let stat_rooted = op_rooted.stat("subdir3/somefile").await.expect("stat");
+
+            let op = new_operator();
+            let stat = op
+                .stat("/myapp/subdir1/subdir2/subdir3/somefile")
+                .await
+                .expect("stat");
+            assert_eq!(stat_rooted, stat_rooted);
+            let stat = op
+                .stat("myapp/subdir1/subdir2/subdir3/somefile")
+                .await
+                .expect("stat");
+            assert_eq!(stat_rooted, stat_rooted);
+        }
+
+        {
+            // simple list
+            let op = new_operator();
+            let mut entries = op.lister("").await.expect("list");
+            while let Some(entry) = entries.try_next().await.expect("next") {
+                console_log!("entry: {} {:?}", entry.path(), entry.metadata().mode());
+            }
+        }
+
+        // list test added here so we are sure there are dirs and files to list
+        {
+            // simple list
+            let op = new_operator();
+            let mut entries = op.lister("myapp/").await.expect("list");
+            while let Some(entry) = entries.try_next().await.expect("next") {
+                console_log!("entry: {} {:?}", entry.path(), entry.metadata().mode());
+            }
+        }
+
+        {
+            // recursive list
+            let op = new_operator();
+            let mut entries = op
+                .lister_with("")
+                .recursive(true)
+                .await
+                .expect("recursive list");
+            while let Some(entry) = entries.try_next().await.expect("next") {
+                console_log!("rec entry: {} {:?}", entry.path(), entry.metadata().mode());
+            }
+        }
+    }
+
+    #[wasm_bindgen_test]
+    async fn test_write() {
+        let op = new_operator();
+
+        // this does not even go to OPFS backend, short-circuited
+        assert_eq!(
+            op.write("/", "should_not_work").await.unwrap_err().kind(),
+            ErrorKind::IsADirectory
+        );
+    }
+
+    #[wasm_bindgen_test]
+    async fn test_write_read_simple() {
+        let path = "/test_file";
+        let content = "Content of the file to write";
+        {
+            let op = new_operator();
+            let meta = op.write(path, content).await.expect("write");
+            console_log!("{:?}", meta);
+            assert_eq!(meta.content_length(), content.len() as u64);
+
+            // This is None - we have to use stat
+            assert!(meta.last_modified().is_none());
+
+            let stat = op.stat(path).await.expect("stat");
+            console_log!("stat = {:?}", stat);
+            assert_eq!(stat.mode(), EntryMode::FILE);
+            assert_eq!(stat.content_length(), content.len() as u64);
+            assert!(stat.last_modified().is_some());
+        }
+
+        {
+            // read back and compare
+            let op = new_operator();
+            let buffer = op.read(path).await.expect("read");
+            console_log!("read = {:?}", buffer);
+            assert_eq!(buffer.to_bytes(), content.as_bytes());
+            op.delete(path).await.expect("delete");
+        }
+    }
+
+    #[wasm_bindgen_test]
+    async fn test_write_write_twice_same_file() {
+        let op = new_operator();
+        let content = "Content of the file to write";
+        let path = "/test_file";
+        let meta = op.write(path, content).await.expect("write");
+        let meta = op.write(path, content).await.expect("write");
+        assert_eq!(meta.content_length(), content.len() as u64);
+        assert!(meta.last_modified().is_none());
+        op.delete(path).await.expect("delete");
+    }
+
+    #[wasm_bindgen_test]
+    async fn test_write_like_append_three_times() {
+        let op = new_operator();
+        let content = "Content of the file to write";
+        let path = "/test_file_write_multiple";
+        let mut w = op.writer(path).await.expect("writer");
+        w.write(content).await.expect("write");
+        w.write(content).await.expect("write");
+        w.write(content).await.expect("write");
+        let meta = w.close().await.expect("close");
+        let expected_file_size = (content.len() as u64) * 3;
+        assert_eq!(meta.content_length(), expected_file_size);
+        assert!(meta.last_modified().is_none());
+        let stat = op.stat(path).await.expect("stat");
+        assert_eq!(stat.content_length(), expected_file_size);
+        op.delete(path).await.expect("delete");
+    }
+
+    #[wasm_bindgen_test]
+    async fn test_write_large_file_quota() {
+        // you can simulate a lower disk space in Chrome
+        let op = new_operator();
+        let path = "big_file";
+        let mut w = op.writer(path).await.expect("writer");
+        let chunk = vec![0u8; 1024 * 1024]; // 1MB
+        for _ in 0..1024 {
+            let res = w.write(chunk.clone()).await;
+            match res {
+                Ok(()) => (),
+                Err(e) => {
+                    // OPFS filled up (you can simulate this in Chrome by setting a lower limit)
+                    // parse_js_error: JsValue(TypeError: Cannot close a ERRORED writable stream
+                    // TypeError: Cannot close a ERRORED writable stream
+                    console_log!("got {e:?}");
+                    console_log!("message = {}", e.message());
+                    assert_eq!(e.kind(), ErrorKind::Unexpected);
+                    return;
+                }
+            }
+        }
+
+        let expected_file_size = 1024 * 1024 * 1024; // 1GB
+        let meta = w.close().await.expect("close");
+        assert_eq!(meta.content_length(), expected_file_size);
+        let stat = op.stat(path).await.expect("stat");
+        assert_eq!(stat.content_length(), expected_file_size);
+
+        {
+            // read and compare
+            let op = new_operator();
+            let buffer = op.read(path).await.expect("read");
+            assert_eq!(buffer.to_bytes().len(), expected_file_size as usize);
+        }
+        op.delete(path).await.expect("delete");
+    }
+
+    #[wasm_bindgen_test]
+    async fn test_write_and_read_with_range() {
+        let op = new_operator();
+        let path = "numbers.txt";
+        let content = "0123456789";
+        let meta = op.write(path, content).await.expect("write");
+        let buffer = op.read_with(path).range(3..5).await.expect("read");
+        assert_eq!(buffer.to_bytes(), "34".as_bytes());
+        op.delete(path).await.expect("delete");
+    }
+}
diff --git a/core/services/opfs/Cargo.toml b/core/services/opfs/Cargo.toml
index 279c66712..6cfb55011 100644
--- a/core/services/opfs/Cargo.toml
+++ b/core/services/opfs/Cargo.toml
@@ -33,17 +33,24 @@ all-features = true
 [target.'cfg(target_arch = "wasm32")'.dependencies]
 js-sys = "0.3.77"
 opendal-core = { path = "../../core", version = "0.56.0", default-features = false }
+send_wrapper = "0.6"
 serde = { workspace = true, features = ["derive"] }
 wasm-bindgen = "0.2.100"
 wasm-bindgen-futures = "0.4.50"
 web-sys = { version = "0.3.77", features = [
-  "Window",
+  "Blob",
+  "console",
+  "DomException",
   "File",
   "FileSystemDirectoryHandle",
   "FileSystemFileHandle",
   "FileSystemGetDirectoryOptions",
   "FileSystemGetFileOptions",
+  "FileSystemRemoveOptions",
   "FileSystemWritableFileStream",
   "Navigator",
+  "WriteCommandType",
+  "WriteParams",
   "StorageManager",
+  "Window",
 ] }
diff --git a/core/services/opfs/src/backend.rs b/core/services/opfs/src/backend.rs
index 9e28868a5..ab50212a5 100644
--- a/core/services/opfs/src/backend.rs
+++ b/core/services/opfs/src/backend.rs
@@ -15,14 +15,21 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use std::fmt::Debug;
 use std::sync::Arc;
 
-use web_sys::FileSystemGetDirectoryOptions;
+use wasm_bindgen::JsCast;
+use wasm_bindgen_futures::JsFuture;
+use web_sys::File;
+use web_sys::FileSystemWritableFileStream;
 
-use super::OPFS_SCHEME;
 use super::config::OpfsConfig;
+use super::core::OpfsCore;
+use super::deleter::OpfsDeleter;
+use super::error::*;
+use super::lister::OpfsLister;
+use super::reader::OpfsReader;
 use super::utils::*;
+use super::writer::OpfsWriter;
 use opendal_core::raw::*;
 use opendal_core::*;
 
@@ -32,44 +39,108 @@ pub struct OpfsBuilder {
     pub(super) config: OpfsConfig,
 }
 
+impl OpfsBuilder {
+    /// Set root directory for this backend.
+    pub fn root(mut self, root: &str) -> Self {
+        self.config.root = if root.is_empty() {
+            None
+        } else {
+            Some(root.to_string())
+        };
+        self
+    }
+}
+
 impl Builder for OpfsBuilder {
     type Config = OpfsConfig;
 
     fn build(self) -> Result<impl Access> {
-        Ok(OpfsBackend {})
+        let root = normalize_root(&self.config.root.unwrap_or_default());
+        let core = Arc::new(OpfsCore::new(root));
+        Ok(OpfsBackend { core })
     }
 }
 
 /// OPFS Service backend
-#[derive(Default, Debug, Clone)]
-pub struct OpfsBackend {}
+#[derive(Debug, Clone)]
+pub struct OpfsBackend {
+    core: Arc<OpfsCore>,
+}
 
 impl Access for OpfsBackend {
-    type Reader = ();
+    type Reader = OpfsReader;
 
-    type Writer = ();
+    type Writer = OpfsWriter;
 
-    type Lister = ();
+    type Lister = OpfsLister;
 
-    type Deleter = ();
+    type Deleter = oio::OneShotDeleter<OpfsDeleter>;
 
     fn info(&self) -> Arc<AccessorInfo> {
-        let info = AccessorInfo::default();
-        info.set_scheme(OPFS_SCHEME);
-        info.set_name("opfs");
-        info.set_root("/");
-        info.set_native_capability(Capability {
-            create_dir: true,
-            ..Default::default()
-        });
-        Arc::new(info)
+        self.core.info.clone()
+    }
+
+    async fn stat(&self, path: &str, _args: OpStat) -> Result<RpStat> {
+        let p = build_abs_path(&self.core.root, path);
+
+        if p.ends_with('/') {
+            get_directory_handle(&p, false).await?;
+            return Ok(RpStat::new(Metadata::new(EntryMode::DIR)));
+        }
+
+        // File: get metadata via getFile().
+        let handle = get_file_handle(&p, false).await?;
+        let file: File = JsFuture::from(handle.get_file())
+            .await
+            .and_then(JsCast::dyn_into)
+            .map_err(parse_js_error)?;
+
+        let mut meta = Metadata::new(EntryMode::FILE);
+        meta.set_content_length(file.size() as u64);
+        if let Ok(t) = Timestamp::from_millisecond(file.last_modified() as i64) {
+            meta.set_last_modified(t);
+        }
+
+        Ok(RpStat::new(meta))
+    }
+
+    async fn read(&self, path: &str, args: OpRead) -> Result<(RpRead, Self::Reader)> {
+        let p = build_abs_path(&self.core.root, path);
+        let handle = get_file_handle(&p, false).await?;
+
+        Ok((RpRead::new(), OpfsReader::new(handle, args.range())))
+    }
+
+    async fn list(&self, path: &str, _args: OpList) -> Result<(RpList, Self::Lister)> {
+        let p = build_abs_path(&self.core.root, path);
+        let dir = get_directory_handle(&p, false).await?;
+
+        Ok((RpList::default(), OpfsLister::new(dir, path.to_string())))
     }
 
     async fn create_dir(&self, path: &str, _: OpCreateDir) -> Result<RpCreateDir> {
-        let opt = FileSystemGetDirectoryOptions::new();
-        opt.set_create(true);
-        get_directory_handle(path, &opt).await?;
+        debug_assert!(path != "/", "root path should be handled upstream");
+        let p = build_abs_path(&self.core.root, path);
+        get_directory_handle(&p, true).await?;
 
         Ok(RpCreateDir::default())
     }
+
+    async fn write(&self, path: &str, _args: OpWrite) -> Result<(RpWrite, Self::Writer)> {
+        let p = build_abs_path(&self.core.root, path);
+        let handle = get_file_handle(&p, true).await?;
+        let stream: FileSystemWritableFileStream = JsFuture::from(handle.create_writable())
+            .await
+            .and_then(JsCast::dyn_into)
+            .map_err(parse_js_error)?;
+
+        Ok((RpWrite::default(), OpfsWriter::new(stream)))
+    }
+
+    async fn delete(&self) -> Result<(RpDelete, Self::Deleter)> {
+        Ok((
+            RpDelete::default(),
+            oio::OneShotDeleter::new(OpfsDeleter::new(self.core.clone())),
+        ))
+    }
 }
diff --git a/core/services/opfs/src/config.rs b/core/services/opfs/src/config.rs
index 38bd72c21..1201dcb00 100644
--- a/core/services/opfs/src/config.rs
+++ b/core/services/opfs/src/config.rs
@@ -26,11 +26,16 @@ use super::backend::OpfsBuilder;
 #[derive(Default, Serialize, Deserialize, Clone, PartialEq, Eq)]
 #[serde(default)]
 #[non_exhaustive]
-pub struct OpfsConfig {}
+pub struct OpfsConfig {
+    /// Root directory for this backend.
+    pub root: Option<String>,
+}
 
 impl Debug for OpfsConfig {
     fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        f.debug_struct("OpfsConfig").finish_non_exhaustive()
+        f.debug_struct("OpfsConfig")
+            .field("root", &self.root)
+            .finish_non_exhaustive()
     }
 }
 
diff --git a/core/services/opfs/src/core.rs b/core/services/opfs/src/core.rs
index 10cd1da91..943accb34 100644
--- a/core/services/opfs/src/core.rs
+++ b/core/services/opfs/src/core.rs
@@ -15,59 +15,45 @@
 // specific language governing permissions and limitations
 // under the License.
 
-use std::fmt::Debug;
+use std::sync::Arc;
 
-use wasm_bindgen::JsCast;
-use wasm_bindgen_futures::JsFuture;
-use web_sys::File;
-use web_sys::FileSystemWritableFileStream;
+use super::OPFS_SCHEME;
+use opendal_core::raw::*;
+use opendal_core::*;
 
-use opendal_core::Error;
-use opendal_core::Result;
-
-use super::error::*;
-use super::utils::*;
-
-#[derive(Default, Debug)]
-pub struct OpfsCore {}
+#[derive(Debug, Clone)]
+pub struct OpfsCore {
+    pub root: String,
+    pub info: Arc<AccessorInfo>,
+}
 
 impl OpfsCore {
-    #[allow(unused)]
-    async fn store_file(&self, file_name: &str, content: &[u8]) -> Result<(), Error> {
-        let handle = get_handle_by_filename(file_name).await?;
+    pub fn new(root: String) -> Self {
+        let info = AccessorInfo::default();
+        info.set_scheme(OPFS_SCHEME);
+        info.set_name("opfs");
+        info.set_root(&root);
+        info.set_native_capability(Capability {
+            stat: true,
 
-        let writable: FileSystemWritableFileStream = JsFuture::from(handle.create_writable())
-            .await
-            .and_then(JsCast::dyn_into)
-            .map_err(parse_js_error)?;
+            read: true,
 
-        JsFuture::from(
-            writable
-                .write_with_u8_array(content)
-                .map_err(parse_js_error)?,
-        )
-        .await
-        .map_err(parse_js_error)?;
+            list: true,
 
-        JsFuture::from(writable.close())
-            .await
-            .map_err(parse_js_error)?;
+            create_dir: true,
 
-        Ok(())
-    }
+            write: true,
+            write_can_empty: true,
+            write_can_multi: true,
 
-    #[allow(unused)]
-    async fn read_file(&self, file_name: &str) -> Result<Vec<u8>, Error> {
-        let handle = get_handle_by_filename(file_name).await?;
+            delete: true,
 
-        let file: File = JsFuture::from(handle.get_file())
-            .await
-            .and_then(JsCast::dyn_into)
-            .map_err(parse_js_error)?;
-        let array_buffer = JsFuture::from(file.array_buffer())
-            .await
-            .map_err(parse_js_error)?;
+            ..Default::default()
+        });
 
-        Ok(js_sys::Uint8Array::new(&array_buffer).to_vec())
+        Self {
+            root,
+            info: Arc::new(info),
+        }
     }
 }
diff --git a/core/services/opfs/src/deleter.rs b/core/services/opfs/src/deleter.rs
new file mode 100644
index 000000000..2d2399743
--- /dev/null
+++ b/core/services/opfs/src/deleter.rs
@@ -0,0 +1,60 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::sync::Arc;
+
+use wasm_bindgen_futures::JsFuture;
+use web_sys::FileSystemRemoveOptions;
+
+use super::core::OpfsCore;
+use super::error::*;
+use super::utils::*;
+use opendal_core::raw::*;
+use opendal_core::*;
+
+pub struct OpfsDeleter {
+    core: Arc<OpfsCore>,
+}
+
+impl OpfsDeleter {
+    pub fn new(core: Arc<OpfsCore>) -> Self {
+        Self { core }
+    }
+}
+
+impl oio::OneShotDelete for OpfsDeleter {
+    async fn delete_once(&self, path: String, args: OpDelete) -> Result<()> {
+        let p = build_abs_path(&self.core.root, &path);
+        let (dir, name) = get_parent_dir_and_name(&p, false).await?;
+
+        let opt = FileSystemRemoveOptions::new();
+        opt.set_recursive(args.recursive());
+
+        match JsFuture::from(dir.remove_entry_with_options(name, &opt)).await {
+            Ok(_) => Ok(()),
+            Err(e) => {
+                let err = parse_js_error(e);
+                // Deleting a non-existent entry is not an error.
+                if err.kind() == ErrorKind::NotFound {
+                    Ok(())
+                } else {
+                    Err(err)
+                }
+            }
+        }
+    }
+}
diff --git a/core/services/opfs/src/docs.md b/core/services/opfs/src/docs.md
index a529c2ff3..fef5f71f3 100644
--- a/core/services/opfs/src/docs.md
+++ b/core/services/opfs/src/docs.md
@@ -2,12 +2,12 @@
 
 This service can be used to:
 
-- [ ] create_dir
-- [ ] stat
-- [ ] read
-- [ ] write
-- [ ] delete
-- [ ] list
+- [x] create_dir
+- [x] stat
+- [x] read
+- [x] write
+- [x] delete
+- [x] list
 - [ ] copy
 - [ ] rename
 - [ ] presign
diff --git a/core/services/opfs/src/error.rs b/core/services/opfs/src/error.rs
index 5f2a1a0fe..b50a223c3 100644
--- a/core/services/opfs/src/error.rs
+++ b/core/services/opfs/src/error.rs
@@ -15,14 +15,39 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use wasm_bindgen::JsCast;
 use wasm_bindgen::JsValue;
+use web_sys::DomException;
 
 use opendal_core::Error;
 use opendal_core::ErrorKind;
 
-pub(crate) fn parse_js_error(msg: JsValue) -> Error {
+pub(crate) fn parse_js_error(value: JsValue) -> Error {
+    if let Some(exc) = value.dyn_ref::<DomException>() {
+        let kind = match exc.name().as_str() {
+            "NotFoundError" => ErrorKind::NotFound,
+            "TypeMismatchError" => ErrorKind::NotFound,
+            "NotAllowedError" => ErrorKind::PermissionDenied,
+            "QuotaExceededError" => ErrorKind::Unexpected,
+            e => {
+                console_debug!("Got unhandled DOM exception {e:?}");
+                ErrorKind::Unexpected
+            }
+        };
+        return Error::new(kind, exc.message());
+    }
+
+    // FIXME: how to map `TypeError`` from `getDirectoryHandle`:
+    // "name specified is not a valid string or contains characters that
+    // would interfere with the native file system"
+    if let Some(err) = value.dyn_ref::<js_sys::Error>() {
+        return Error::new(ErrorKind::Unexpected, String::from(err.message()));
+    }
+
     Error::new(
         ErrorKind::Unexpected,
-        msg.as_string().unwrap_or_else(String::new),
+        value
+            .as_string()
+            .unwrap_or_else(|| "unknown JS error".to_string()),
     )
 }
diff --git a/core/services/opfs/src/lib.rs b/core/services/opfs/src/lib.rs
index 06887f57f..42ba6a7ab 100644
--- a/core/services/opfs/src/lib.rs
+++ b/core/services/opfs/src/lib.rs
@@ -25,11 +25,22 @@ pub fn register_opfs_service(registry: &opendal_core::OperatorRegistry) {
     registry.register::<Opfs>(OPFS_SCHEME);
 }
 
+macro_rules! console_debug {
+    ($($arg:tt)*) => {
+        #[cfg(debug_assertions)]
+        web_sys::console::log_1(&format!($($arg)*).into())
+      };
+}
+
 mod backend;
 mod config;
 mod core;
+mod deleter;
 mod error;
+mod lister;
+mod reader;
 mod utils;
+mod writer;
 
 pub use backend::OpfsBuilder as Opfs;
 pub use config::OpfsConfig;
diff --git a/core/services/opfs/src/lister.rs b/core/services/opfs/src/lister.rs
new file mode 100644
index 000000000..e188bf6f8
--- /dev/null
+++ b/core/services/opfs/src/lister.rs
@@ -0,0 +1,76 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use send_wrapper::SendWrapper;
+use wasm_bindgen::prelude::*;
+use wasm_bindgen_futures::JsFuture;
+use web_sys::FileSystemDirectoryHandle;
+
+use super::error::*;
+use opendal_core::raw::*;
+use opendal_core::*;
+
+pub struct OpfsLister {
+    iter: SendWrapper<js_sys::AsyncIterator>,
+    path: String,
+}
+
+impl OpfsLister {
+    pub fn new(dir: FileSystemDirectoryHandle, path: String) -> Self {
+        // Entry paths must not start with '/'.
+        // For root listing, path is "/" — normalize to "".
+        let path = if path == "/" { String::new() } else { path };
+        Self {
+            iter: SendWrapper::new(dir.entries()),
+            path,
+        }
+    }
+}
+
+impl oio::List for OpfsLister {
+    async fn next(&mut self) -> Result<Option<oio::Entry>> {
+        let result = JsFuture::from(self.iter.next().map_err(parse_js_error)?)
+            .await
+            .map_err(parse_js_error)?;
+
+        let done = js_sys::Reflect::get(&result, &"done".into())
+            .unwrap_or(JsValue::TRUE)
+            .as_bool()
+            .unwrap_or(true);
+        if done {
+            return Ok(None);
+        }
+
+        let value = js_sys::Reflect::get(&result, &"value".into()).map_err(parse_js_error)?;
+        let pair: js_sys::Array = value.unchecked_into();
+        let name = pair.get(0).as_string().unwrap_or_default();
+        let handle = pair.get(1);
+
+        let kind = js_sys::Reflect::get(&handle, &"kind".into())
+            .ok()
+            .and_then(|v| v.as_string())
+            .unwrap_or_default();
+
+        let (entry_path, mode) = if kind == "directory" {
+            (format!("{}{}/", self.path, name), EntryMode::DIR)
+        } else {
+            (format!("{}{}", self.path, name), EntryMode::FILE)
+        };
+
+        Ok(Some(oio::Entry::new(&entry_path, Metadata::new(mode))))
+    }
+}
diff --git a/core/services/opfs/src/reader.rs b/core/services/opfs/src/reader.rs
new file mode 100644
index 000000000..418a281af
--- /dev/null
+++ b/core/services/opfs/src/reader.rs
@@ -0,0 +1,75 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use send_wrapper::SendWrapper;
+use wasm_bindgen::JsCast;
+use wasm_bindgen_futures::JsFuture;
+use web_sys::FileSystemFileHandle;
+
+use super::error::*;
+use opendal_core::raw::*;
+use opendal_core::*;
+
+pub struct OpfsReader {
+    handle: SendWrapper<FileSystemFileHandle>,
+    range: BytesRange,
+    done: bool,
+}
+
+impl OpfsReader {
+    pub fn new(handle: FileSystemFileHandle, range: BytesRange) -> Self {
+        Self {
+            handle: SendWrapper::new(handle),
+            range,
+            done: false,
+        }
+    }
+}
+
+impl oio::Read for OpfsReader {
+    async fn read(&mut self) -> Result<Buffer> {
+        if self.done {
+            return Ok(Buffer::new());
+        }
+        self.done = true;
+
+        let file: web_sys::File = JsFuture::from(self.handle.get_file())
+            .await
+            .and_then(JsCast::dyn_into)
+            .map_err(parse_js_error)?;
+
+        let blob: &web_sys::Blob = file.as_ref();
+        let blob = if self.range.is_full() {
+            blob.clone()
+        } else {
+            let offset = self.range.offset() as f64;
+            let end = match self.range.size() {
+                Some(size) => offset + size as f64,
+                None => blob.size(),
+            };
+            blob.slice_with_f64_and_f64(offset, end)
+                .map_err(parse_js_error)?
+        };
+
+        let array_buffer = JsFuture::from(blob.array_buffer())
+            .await
+            .map_err(parse_js_error)?;
+        let uint8_array = js_sys::Uint8Array::new(&array_buffer);
+
+        Ok(Buffer::from(uint8_array.to_vec()))
+    }
+}
diff --git a/core/services/opfs/src/utils.rs b/core/services/opfs/src/utils.rs
index 4c2997a73..ec49caefd 100644
--- a/core/services/opfs/src/utils.rs
+++ b/core/services/opfs/src/utils.rs
@@ -26,24 +26,34 @@ use web_sys::window;
 
 use super::error::*;
 
+/// Get the OPFS root directory handle.
 pub(crate) async fn get_root_directory_handle() -> Result<FileSystemDirectoryHandle> {
     let navigator = window().unwrap().navigator();
     let storage_manager = navigator.storage();
+    // This may fail if not secure (not: HTTPS or localhost)
     JsFuture::from(storage_manager.get_directory())
         .await
         .and_then(JsCast::dyn_into)
         .map_err(parse_js_error)
 }
 
+/// Navigate to a directory handle by path.
+///
+/// When `create` is true, intermediate directories are created as needed.
 pub(crate) async fn get_directory_handle(
-    dir: &str,
-    dir_opt: &FileSystemGetDirectoryOptions,
+    path: &str,
+    create: bool,
 ) -> Result<FileSystemDirectoryHandle> {
-    let dirs: Vec<&str> = dir.trim_matches('/').split('/').collect();
+    let opt = FileSystemGetDirectoryOptions::new();
+    opt.set_create(create);
 
+    let trimmed = path.trim_matches('/');
     let mut handle = get_root_directory_handle().await?;
-    for dir in dirs {
-        handle = JsFuture::from(handle.get_directory_handle_with_options(dir, dir_opt))
+    if trimmed.is_empty() {
+        return Ok(handle);
+    }
+    for dir in trimmed.split('/') {
+        handle = JsFuture::from(handle.get_directory_handle_with_options(dir, &opt))
             .await
             .and_then(JsCast::dyn_into)
             .map_err(parse_js_error)?;
@@ -52,18 +62,36 @@ pub(crate) async fn get_directory_handle(
     Ok(handle)
 }
 
-pub(crate) async fn get_handle_by_filename(filename: &str) -> Result<FileSystemFileHandle> {
-    let navigator = window().unwrap().navigator();
-    let storage_manager = navigator.storage();
-    let root: FileSystemDirectoryHandle = JsFuture::from(storage_manager.get_directory())
-        .await
-        .and_then(JsCast::dyn_into)
-        .map_err(parse_js_error)?;
+/// Split a file path into its parent directory handle and filename.
+///
+/// When `create` is true, intermediate directories are created as needed.
+pub(crate) async fn get_parent_dir_and_name<'a>(
+    path: &'a str,
+    create: bool,
+) -> Result<(FileSystemDirectoryHandle, &'a str)> {
+    let trimmed = path.trim_matches('/');
+    match trimmed.rsplit_once('/') {
+        Some((parent, name)) => {
+            let dir = get_directory_handle(parent, create).await?;
+            Ok((dir, name))
+        }
+        None => {
+            let root = get_root_directory_handle().await?;
+            Ok((root, trimmed))
+        }
+    }
+}
+
+/// Get a file handle by its full path.
+///
+/// When `create` is true, intermediate directories and the file itself are created as needed.
+pub(crate) async fn get_file_handle(path: &str, create: bool) -> Result<FileSystemFileHandle> {
+    let (dir, name) = get_parent_dir_and_name(path, create).await?;
 
     let opt = FileSystemGetFileOptions::new();
-    opt.set_create(true);
+    opt.set_create(create);
 
-    JsFuture::from(root.get_file_handle_with_options(filename, &opt))
+    JsFuture::from(dir.get_file_handle_with_options(name, &opt))
         .await
         .and_then(JsCast::dyn_into)
         .map_err(parse_js_error)
diff --git a/core/services/opfs/src/writer.rs b/core/services/opfs/src/writer.rs
new file mode 100644
index 000000000..551131ae3
--- /dev/null
+++ b/core/services/opfs/src/writer.rs
@@ -0,0 +1,80 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use send_wrapper::SendWrapper;
+use wasm_bindgen::JsValue;
+use wasm_bindgen_futures::JsFuture;
+use web_sys::FileSystemWritableFileStream;
+use web_sys::WriteCommandType;
+use web_sys::WriteParams;
+
+use opendal_core::raw::*;
+use opendal_core::*;
+
+use super::error::*;
+
+pub struct OpfsWriter {
+    stream: SendWrapper<FileSystemWritableFileStream>,
+    bytes_written: u64,
+}
+
+impl OpfsWriter {
+    pub fn new(stream: FileSystemWritableFileStream) -> Self {
+        Self {
+            stream: SendWrapper::new(stream),
+            bytes_written: 0,
+        }
+    }
+}
+
+impl oio::Write for OpfsWriter {
+    async fn write(&mut self, bs: Buffer) -> Result<()> {
+        let bytes = bs.to_bytes();
+        let params = WriteParams::new(WriteCommandType::Write);
+        params.set_size(Some(bytes.len() as f64));
+        let data: JsValue = js_sys::Uint8Array::from(bytes.as_ref()).into();
+        params.set_data(&data);
+        JsFuture::from(
+            self.stream
+                .write_with_write_params(&params.into())
+                .map_err(parse_js_error)?,
+        )
+        .await
+        .map_err(parse_js_error)?;
+
+        self.bytes_written += bytes.len() as u64;
+        Ok(())
+    }
+
+    async fn close(&mut self) -> Result<Metadata> {
+        JsFuture::from(self.stream.close())
+            .await
+            .map_err(parse_js_error)?;
+
+        // We cannot set LastModified here - stream does not have such metadata
+        let mut meta = Metadata::new(EntryMode::FILE);
+        meta.set_content_length(self.bytes_written);
+        Ok(meta)
+    }
+
+    async fn abort(&mut self) -> Result<()> {
+        JsFuture::from(self.stream.abort())
+            .await
+            .map_err(parse_js_error)?;
+        Ok(())
+    }
+}