Skip to content

Commit a9f38e6

Browse files
feat(wasm): add browser WASM build target with wasm-bindgen PupClient
Adds wasm32-unknown-unknown as a build target alongside the existing native and WASI targets. The browser build produces an npm-ready package with a PupClient JS class exposed via wasm-bindgen. - Add [lib] section and `browser` feature flag to Cargo.toml - Create src/lib.rs with PupClient + PupClientOptions wasm-bindgen exports (18 typed methods + 5 raw HTTP methods, returns native JS objects) - Gate env/fs APIs in config.rs behind #[cfg(not(feature = "browser"))], add Config::from_params() for browser use - Add LocalStorageBackend in auth/storage.rs for browser token persistence - Make auth/crypto deps (rand, sha2, base64, aes-gcm, etc.) optional to avoid getrandom v0.3 issues on wasm32-unknown-unknown - Add getrandom_backend="wasm_js" rustflag in .cargo/config.toml - Add browser-wasm-build CI job, add pkg/ to .gitignore Build: wasm-pack build --target web --no-default-features --features browser Output: 479KB .wasm + JS/TS bindings with full type definitions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ca8bda9 commit a9f38e6

8 files changed

Lines changed: 551 additions & 26 deletions

File tree

.cargo/config.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
[target.wasm32-wasip2]
22
runner = "wasmtime run --"
3+
4+
# getrandom 0.3 requires explicit backend selection for browser WASM
5+
[target.wasm32-unknown-unknown]
6+
rustflags = ['--cfg', 'getrandom_backend="wasm_js"']

.github/workflows/ci.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,22 @@ jobs:
100100
- name: Report WASM size
101101
run: ls -lh target/wasm32-wasip2/release/pup.wasm
102102

103+
browser-wasm-build:
104+
name: Browser WASM Build
105+
runs-on: ubuntu-latest
106+
steps:
107+
- uses: actions/checkout@v4
108+
- uses: dtolnay/rust-toolchain@stable
109+
with:
110+
targets: wasm32-unknown-unknown
111+
- uses: Swatinem/rust-cache@v2
112+
- name: Install wasm-pack
113+
run: cargo install wasm-pack
114+
- name: Build
115+
run: wasm-pack build --target web --no-default-features --features browser
116+
- name: Report size
117+
run: ls -lh pkg/pup_wasm_bg.wasm
118+
103119
binary-size:
104120
name: Binary Size
105121
runs-on: ubuntu-latest

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ Thumbs.db
2121
.env.local
2222
.envrc
2323

24+
# wasm-pack output
25+
pkg/
26+
2427
# Test artifacts
2528
tests/mockdd/mockdd
2629
tests/compare_bin

Cargo.lock

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

Cargo.toml

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ name = "pup"
33
version = "0.22.0"
44
edition = "2021"
55

6+
[lib]
7+
name = "pup_wasm"
8+
path = "src/lib.rs"
9+
crate-type = ["cdylib", "rlib"]
10+
611
[features]
712
default = ["native"]
813
native = [
@@ -13,15 +18,47 @@ native = [
1318
"dep:reqwest-middleware",
1419
"dep:async-trait",
1520
"dep:task-local-extensions",
21+
"dep:sha2",
22+
"dep:base64",
23+
"dep:rand",
24+
"dep:url",
25+
"dep:aes-gcm",
26+
"dep:uuid",
27+
"dep:chrono",
28+
"dep:regex",
29+
"dep:clap",
30+
"dep:clap_complete",
1631
"tokio/full",
1732
"comfy-table/tty",
1833
]
19-
wasi = ["tokio/rt", "tokio/macros", "reqwest/rustls-tls"]
34+
wasi = [
35+
"dep:sha2",
36+
"dep:base64",
37+
"dep:rand",
38+
"dep:url",
39+
"dep:aes-gcm",
40+
"dep:uuid",
41+
"dep:chrono",
42+
"dep:regex",
43+
"dep:clap",
44+
"dep:clap_complete",
45+
"tokio/rt",
46+
"tokio/macros",
47+
"reqwest/rustls-tls",
48+
]
49+
browser = [
50+
"dep:wasm-bindgen",
51+
"dep:wasm-bindgen-futures",
52+
"dep:js-sys",
53+
"dep:web-sys",
54+
"dep:serde-wasm-bindgen",
55+
"dep:getrandom",
56+
]
2057

2158
[dependencies]
22-
# CLI
23-
clap = { version = "4", features = ["derive"] }
24-
clap_complete = "4.5.66"
59+
# CLI (optional — not needed for browser WASM library)
60+
clap = { version = "4", features = ["derive"], optional = true }
61+
clap_complete = { version = "4.5.66", optional = true }
2562

2663
# Async runtime (features selected by native/wasi feature flags)
2764
tokio = { version = "1", default-features = false }
@@ -31,30 +68,30 @@ serde = { version = "1", features = ["derive"] }
3168
serde_json = { version = "1", features = ["preserve_order"] }
3269
serde_yaml = "0.9"
3370

34-
# HTTP (version-matched to DD client for native; used directly for WASI)
71+
# HTTP (version-matched to DD client for native; used directly for WASI/browser)
3572
reqwest = { version = "0.11", features = ["json"] }
3673

3774
# Error handling
3875
anyhow = "1"
3976

4077
# UUID parsing (version-matched to DD client)
41-
uuid = "1"
78+
uuid = { version = "1", optional = true }
4279

4380
# Time
44-
chrono = "0.4"
45-
regex = "1"
81+
chrono = { version = "0.4", optional = true }
82+
regex = { version = "1", optional = true }
4683

4784
# Output formatting (tty feature disabled for WASM — no crossterm)
4885
comfy-table = { version = "7", default-features = false }
4986

50-
# Auth — OAuth2 PKCE + token storage
51-
sha2 = "0.10"
52-
base64 = "0.22"
53-
rand = "0.9"
54-
url = "2"
87+
# Auth — OAuth2 PKCE + token storage (optional — not needed for browser)
88+
sha2 = { version = "0.10", optional = true }
89+
base64 = { version = "0.22", optional = true }
90+
rand = { version = "0.9", optional = true }
91+
url = { version = "2", optional = true }
5592

56-
# Crypto for fallback storage
57-
aes-gcm = "0.10"
93+
# Crypto for fallback storage (optional — not needed for browser)
94+
aes-gcm = { version = "0.10", optional = true }
5895

5996
# ---- Native-only dependencies ----
6097

@@ -74,3 +111,13 @@ dirs = { version = "6", optional = true }
74111

75112
# Browser opening for OAuth login
76113
open = { version = "5", optional = true }
114+
115+
# ---- Browser WASM dependencies (wasm-bindgen) ----
116+
wasm-bindgen = { version = "0.2", optional = true }
117+
wasm-bindgen-futures = { version = "0.4", optional = true }
118+
js-sys = { version = "0.3", optional = true }
119+
web-sys = { version = "0.3", features = ["Window", "Storage"], optional = true }
120+
serde-wasm-bindgen = { version = "0.6", optional = true }
121+
122+
# RNG support in browser WASM (getrandom 0.2 with JS backend)
123+
getrandom = { version = "0.2", features = ["js"], optional = true }

src/auth/storage.rs

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,17 @@ pub trait Storage: Send + Sync {
2424
pub enum BackendType {
2525
Keychain,
2626
File,
27+
#[cfg(feature = "browser")]
28+
LocalStorage,
2729
}
2830

2931
impl std::fmt::Display for BackendType {
3032
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3133
match self {
3234
BackendType::Keychain => write!(f, "keychain"),
3335
BackendType::File => write!(f, "file"),
36+
#[cfg(feature = "browser")]
37+
BackendType::LocalStorage => write!(f, "localStorage"),
3438
}
3539
}
3640
}
@@ -268,6 +272,95 @@ impl Storage for InMemoryStorage {
268272
}
269273
}
270274

275+
// ---------------------------------------------------------------------------
276+
// LocalStorage backend (browser WASM) — persists tokens across page reloads
277+
// ---------------------------------------------------------------------------
278+
279+
#[cfg(feature = "browser")]
280+
pub struct LocalStorageBackend;
281+
282+
#[cfg(feature = "browser")]
283+
impl LocalStorageBackend {
284+
fn storage() -> Result<web_sys::Storage> {
285+
let window = web_sys::window()
286+
.ok_or_else(|| anyhow::anyhow!("no global window object"))?;
287+
window
288+
.local_storage()
289+
.map_err(|_| anyhow::anyhow!("localStorage not available"))?
290+
.ok_or_else(|| anyhow::anyhow!("localStorage returned None"))
291+
}
292+
293+
fn get_item(key: &str) -> Result<Option<String>> {
294+
let storage = Self::storage()?;
295+
storage
296+
.get_item(key)
297+
.map_err(|_| anyhow::anyhow!("failed to read from localStorage"))
298+
}
299+
300+
fn set_item(key: &str, value: &str) -> Result<()> {
301+
let storage = Self::storage()?;
302+
storage
303+
.set_item(key, value)
304+
.map_err(|_| anyhow::anyhow!("failed to write to localStorage"))
305+
}
306+
307+
fn remove_item(key: &str) -> Result<()> {
308+
let storage = Self::storage()?;
309+
storage
310+
.remove_item(key)
311+
.map_err(|_| anyhow::anyhow!("failed to remove from localStorage"))
312+
}
313+
}
314+
315+
#[cfg(feature = "browser")]
316+
impl Storage for LocalStorageBackend {
317+
fn backend_type(&self) -> BackendType {
318+
BackendType::LocalStorage
319+
}
320+
321+
fn storage_location(&self) -> String {
322+
"browser localStorage".to_string()
323+
}
324+
325+
fn save_tokens(&self, site: &str, tokens: &TokenSet) -> Result<()> {
326+
let key = format!("pup_tokens_{}", sanitize(site));
327+
let json = serde_json::to_string(tokens)?;
328+
Self::set_item(&key, &json)
329+
}
330+
331+
fn load_tokens(&self, site: &str) -> Result<Option<TokenSet>> {
332+
let key = format!("pup_tokens_{}", sanitize(site));
333+
match Self::get_item(&key)? {
334+
Some(json) => Ok(Some(serde_json::from_str(&json)?)),
335+
None => Ok(None),
336+
}
337+
}
338+
339+
fn delete_tokens(&self, site: &str) -> Result<()> {
340+
let key = format!("pup_tokens_{}", sanitize(site));
341+
Self::remove_item(&key)
342+
}
343+
344+
fn save_client_credentials(&self, site: &str, creds: &ClientCredentials) -> Result<()> {
345+
let key = format!("pup_client_{}", sanitize(site));
346+
let json = serde_json::to_string(creds)?;
347+
Self::set_item(&key, &json)
348+
}
349+
350+
fn load_client_credentials(&self, site: &str) -> Result<Option<ClientCredentials>> {
351+
let key = format!("pup_client_{}", sanitize(site));
352+
match Self::get_item(&key)? {
353+
Some(json) => Ok(Some(serde_json::from_str(&json)?)),
354+
None => Ok(None),
355+
}
356+
}
357+
358+
fn delete_client_credentials(&self, site: &str) -> Result<()> {
359+
let key = format!("pup_client_{}", sanitize(site));
360+
Self::remove_item(&key)
361+
}
362+
}
363+
271364
// ---------------------------------------------------------------------------
272365
// Factory — auto-detect backend, with fallback
273366
// ---------------------------------------------------------------------------
@@ -307,11 +400,16 @@ fn detect_backend() -> Box<dyn Storage> {
307400
}
308401
}
309402

310-
#[cfg(target_arch = "wasm32")]
403+
#[cfg(all(target_arch = "wasm32", not(feature = "browser")))]
311404
fn detect_backend() -> Box<dyn Storage> {
312405
Box::new(InMemoryStorage)
313406
}
314407

408+
#[cfg(feature = "browser")]
409+
fn detect_backend() -> Box<dyn Storage> {
410+
Box::new(LocalStorageBackend)
411+
}
412+
315413
// ---------------------------------------------------------------------------
316414
// Helpers
317415
// ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)