Skip to content

Commit de346d6

Browse files
authored
feat: added file capability [APMSP-3780] (#177)
* feat: added file capability * fix: JS tomfoolery * chore: change input to main now that libdatadog's side was merged * fix: post bump fixes * fix: review * fix: address comments * fix: detached buffer thing * fix: transport for files renamed, uniform lazy loading
1 parent d99947f commit de346d6

11 files changed

Lines changed: 357 additions & 60 deletions

File tree

Cargo.lock

Lines changed: 29 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/capabilities/Cargo.toml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,7 @@ http = "1"
1515
bytes = "1.4"
1616
futures-core = "0.3"
1717
anyhow = "1"
18-
# TODO: Replace this temporary libdatadog PR rev with the official release/tag
19-
# that contains DataDog/libdatadog#2235, then regenerate Cargo.lock.
20-
libdd-capabilities = { git = "https://github.com/DataDog/libdatadog.git", rev = "8cd68ab922fb7aade0f089ccfc91291d874673af" }
18+
libdd-capabilities = { git = "https://github.com/DataDog/libdatadog.git", rev = "3081603d3c74f209be4e3be951f78a1a7469397f" }
2119

2220
[dev-dependencies]
2321
wasm-bindgen-test = "0.3"

crates/capabilities/src/file.rs

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Wasm implementation of [`FileCapability`] backed by Node.js `fs`.
5+
//!
6+
//! The JS transport is imported via `wasm_bindgen(module = ...)` from
7+
//! `filesystem.js`, which ships alongside the wasm output.
8+
9+
use std::future::Future;
10+
11+
use bytes::Bytes;
12+
use js_sys::{self, Reflect, Uint8Array};
13+
use wasm_bindgen::prelude::*;
14+
use wasm_bindgen_futures::JsFuture;
15+
16+
use libdd_capabilities::file::{FileCapability, FileError, FileMetadata};
17+
use libdd_capabilities::maybe_send::MaybeSend;
18+
19+
#[wasm_bindgen(module = "/src/filesystem.js")]
20+
extern "C" {
21+
#[wasm_bindgen(js_name = "readFile", catch)]
22+
fn js_read_file(path: &str) -> Result<js_sys::Promise, JsValue>;
23+
24+
#[wasm_bindgen(js_name = "writeFile", catch)]
25+
fn js_write_file(path: &str, data: &[u8]) -> Result<js_sys::Promise, JsValue>;
26+
27+
#[wasm_bindgen(js_name = "metadata", catch)]
28+
fn js_metadata(path: &str) -> Result<js_sys::Promise, JsValue>;
29+
30+
#[wasm_bindgen(js_name = "exists", catch)]
31+
fn js_exists(path: &str) -> Result<js_sys::Promise, JsValue>;
32+
}
33+
34+
#[derive(Debug, Clone)]
35+
pub struct WasmFileCapability;
36+
37+
impl FileCapability for WasmFileCapability {
38+
fn new() -> Self {
39+
Self
40+
}
41+
42+
#[allow(clippy::manual_async_fn)]
43+
fn read(&self, path: &str) -> impl Future<Output = Result<Bytes, FileError>> + MaybeSend {
44+
let path = path.to_owned();
45+
async move {
46+
let promise =
47+
js_read_file(&path).map_err(|e| map_js_error(&e, &path))?;
48+
let value = JsFuture::from(promise)
49+
.await
50+
.map_err(|e| map_js_error(&e, &path))?;
51+
let array = Uint8Array::unchecked_from_js(value);
52+
Ok(Bytes::from(array.to_vec()))
53+
}
54+
}
55+
56+
#[allow(clippy::manual_async_fn)]
57+
fn write(
58+
&self,
59+
path: &str,
60+
contents: Bytes,
61+
) -> impl Future<Output = Result<(), FileError>> + MaybeSend {
62+
let path = path.to_owned();
63+
async move {
64+
let promise = js_write_file(&path, &contents)
65+
.map_err(|e| map_js_error(&e, &path))?;
66+
JsFuture::from(promise)
67+
.await
68+
.map_err(|e| map_js_error(&e, &path))?;
69+
Ok(())
70+
}
71+
}
72+
73+
#[allow(clippy::manual_async_fn)]
74+
fn metadata(
75+
&self,
76+
path: &str,
77+
) -> impl Future<Output = Result<FileMetadata, FileError>> + MaybeSend {
78+
let path = path.to_owned();
79+
async move {
80+
let promise =
81+
js_metadata(&path).map_err(|e| map_js_error(&e, &path))?;
82+
let value = JsFuture::from(promise)
83+
.await
84+
.map_err(|e| map_js_error(&e, &path))?;
85+
parse_metadata(&value, &path)
86+
}
87+
}
88+
89+
#[allow(clippy::manual_async_fn)]
90+
fn exists(&self, path: &str) -> impl Future<Output = Result<bool, FileError>> + MaybeSend {
91+
let path = path.to_owned();
92+
async move {
93+
let promise = js_exists(&path).map_err(|e| map_js_error(&e, &path))?;
94+
let value = JsFuture::from(promise)
95+
.await
96+
.map_err(|e| map_js_error(&e, &path))?;
97+
value
98+
.as_bool()
99+
.ok_or_else(|| FileError::Io(anyhow::anyhow!("exists({path}) did not return a boolean")))
100+
}
101+
}
102+
}
103+
104+
fn map_js_error(err: &JsValue, path: &str) -> FileError {
105+
let code = Reflect::get(err, &JsValue::from_str("code"))
106+
.ok()
107+
.and_then(|v| v.as_string());
108+
match code.as_deref() {
109+
Some("ENOENT") => FileError::NotFound(path.to_owned()),
110+
Some("EACCES") | Some("EPERM") => FileError::PermissionDenied(path.to_owned()),
111+
_ => {
112+
let message = Reflect::get(err, &JsValue::from_str("message"))
113+
.ok()
114+
.and_then(|v| v.as_string())
115+
.unwrap_or_else(|| format!("{err:?}"));
116+
FileError::Io(anyhow::anyhow!("{message} (path: {path})"))
117+
}
118+
}
119+
}
120+
121+
fn parse_metadata(value: &JsValue, path: &str) -> Result<FileMetadata, FileError> {
122+
let size = read_bigint_u64(value, "size", path)?;
123+
// Node populates `stat().ino` on every platform, so `inode` is always Some.
124+
let inode = Some(read_bigint_u64(value, "inode", path)?);
125+
let is_file = read_bool(value, "is_file", path)?;
126+
let is_dir = read_bool(value, "is_dir", path)?;
127+
Ok(FileMetadata {
128+
size,
129+
inode,
130+
is_file,
131+
is_dir,
132+
})
133+
}
134+
135+
fn read_bigint_u64(value: &JsValue, key: &str, path: &str) -> Result<u64, FileError> {
136+
let v = Reflect::get(value, &JsValue::from_str(key))
137+
.map_err(|_| FileError::Io(anyhow::anyhow!("metadata({path}) could not read field `{key}`")))?;
138+
if v.is_undefined() || v.is_null() {
139+
return Err(FileError::Io(anyhow::anyhow!("metadata({path}) missing field `{key}`")));
140+
}
141+
let bigint = js_sys::BigInt::try_from(v)
142+
.map_err(|_| FileError::Io(anyhow::anyhow!("metadata({path}) field `{key}` is not a BigInt")))?;
143+
u64::try_from(bigint)
144+
.map_err(|_| FileError::Io(anyhow::anyhow!("metadata({path}) field `{key}` overflows u64")))
145+
}
146+
147+
fn read_bool(value: &JsValue, key: &str, path: &str) -> Result<bool, FileError> {
148+
Reflect::get(value, &JsValue::from_str(key))
149+
.ok()
150+
.and_then(|v| v.as_bool())
151+
.ok_or_else(|| {
152+
FileError::Io(anyhow::anyhow!(
153+
"metadata({path}) is missing boolean field `{key}`"
154+
))
155+
})
156+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Lazy `require('node:fs')` — see http_transport.js. The cached accessor
2+
// avoids paying the module-resolution cost on every call.
3+
4+
'use strict'
5+
6+
let _fs
7+
function fs () {
8+
return _fs ??= require('node:fs')
9+
}
10+
11+
module.exports.readFile = function (path) {
12+
return fs().promises.readFile(path)
13+
}
14+
15+
module.exports.writeFile = function (path, data) {
16+
// Copy off the wasm-memory view before the async write; a memory grow would
17+
// otherwise detach the underlying ArrayBuffer mid-write.
18+
return fs().promises.writeFile(path, Buffer.from(data))
19+
}
20+
21+
module.exports.metadata = function (path) {
22+
return fs().promises.stat(path, { bigint: true }).then(s => ({
23+
size: s.size,
24+
inode: s.ino,
25+
is_file: s.isFile(),
26+
is_dir: s.isDirectory(),
27+
}))
28+
}
29+
30+
module.exports.exists = function (path) {
31+
return fs().promises.stat(path).then(
32+
() => true,
33+
(error) => {
34+
if (error && error.code === 'ENOENT') return false
35+
throw error
36+
},
37+
)
38+
}

0 commit comments

Comments
 (0)