@ahmedtooper_npm/hamd-wasm
v0.1.0
Published
Unified, encrypted, type-safe browser storage — five backends (localStorage, sessionStorage, cookies, memory, IndexedDB) with AES-256-GCM, TTL, and cross-tab sync, built in Rust and compiled to WebAssembly
Maintainers
Readme
hamd-wasm
Hamd — one API, five storages, encrypted, TTL-aware, binary-ready. Rust → WebAssembly. Works anywhere JavaScript runs.
Pick a type, get a backend.
new Local()=localStorage.new Session()=sessionStorage.new Cookies()=document.cookie.new Memory()= in-memory.new IndexedDb()=IndexedDB(async). All share the same methods.
import { Local, IndexedDb } from '@ahmedtrooper/hamd-wasm';
const store = new Local('myapp:');
store.set('user', { name: 'Alice', id: 101 });
store.get('user'); // → { name: 'Alice', id: 101 }
const db = new IndexedDb();
await db.setBytes('avatar', new Uint8Array([0, 255, 42]));
await db.getBytes('avatar'); // → Uint8ArrayFor non-developers — what is this?
- Browser storage is where a website saves small data on your device (login, cart, settings). Different places exist:
localStorage(stays),sessionStorage(tab-only),cookies(sent to server),IndexedDB(big files). - Hamd gives you one simple way to use any of them. Change
new Local()tonew IndexedDb()— your code stays the same. - Encryption means saved data looks like gibberish in DevTools — readable only with your 32-byte key.
- TTL means “expire after 60 seconds” — good for OTPs, invites, temporary locks.
- Binary means you can save images/files as
Uint8Array, not just text. - Sync means if a user has two tabs open, a cart update in tab A appears in tab B.
Install
JavaScript / TypeScript (npm)
npm install @ahmedtrooper/hamd-wasm
# docs site: npm --prefix web install && npm --prefix web run buildNo Rust needed — npm ships .wasm + JS glue + .d.ts.
Rust (crates.io)
[dependencies]
hamd-wasm = "0.1.0"Build from source
cargo fmt --all
cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo check --target wasm32-unknown-unknown
wasm-pack build --target bundler --release --scope ahmedtrooper # pkg/ 188K wasmChoose a backend
| Type | Backed by | Sync / Async | Best for | Limit |
| --- | --- | --- | --- | --- |
| new Local(prefix?) | window.localStorage | sync | app data that survives restarts | ~5MB string (~3.6MB binary base64). Quota → purge expired retry |
| new Session(prefix?) | window.sessionStorage | sync | tab-only data | ~5MB |
| new Cookies(prefix?) | document.cookie | sync | server-readable tiny tokens | 4KB per cookie (3900 guard, SameSite=Lax, Secure on https) |
| new Memory(prefix?) | HashMap | sync | SSR, tests, fallback when window missing | unbounded |
| new IndexedDb(prefix?) | IndexedDB hamd v1 kv | async (Promise) | files, images, large data | disk ~50% (GBs) |
All take an optional prefix (hamd: default) to isolate: new Local('app:') and new Local('admin:') never collide, clear() only deletes its prefix.
const a = new Local('app:');
const b = new Local('admin:');
a.set('x', 1); b.set('x', 2);
a.clear(); // b still has 'x'Complete API — every method, no omission
Every type implements the same names. IndexedDb returns Promise for storage ops; constructors, enableEncryption/generateKey, subscribe stay sync.
new Type(prefix?)
const s1 = new Local(); // prefix "hamd:"
const s2 = new Local('myapp:'); // custom
const s3 = new Session(null); // also "hamd:"prefixifnull/undefined→"hamd:". Use per feature:orders:,auth:.
set(key, value, ttlMs?) — save JSON
store.set('user', { name: 'Alice', age: 25 });
store.set('count', 42);
store.set('otp', '123456', 60_000); // TTL 60s → envelope {__val, __exp: Date.now()+60_000}key: string— must satisfy validation (applies to everykeyparam): non-empty,≤256bytes, no\0\n\r→ elsekey must be non-empty/key too long: max 256 bytes/key contains invalid control charactersvalue: any—JSON.stringify'd; primitives, objects, arrays all OKttlMs?: number|null— if given must befinite && >0elsettlMs must be a positive finite number- Storage is
hex(nonce||ciphertext)if encryption enabled, else JSON or TTL-envelope JSON
get(key) — load JSON
store.get('user'); // → {name:'Alice'} or null if missing/expired
store.get('otp'); // → value or null after 60s (entry auto-removed)- Lazy-eviction: expired
has/getdeletes the raw key then returnsnull - Errors:
keyvalidation same asset; decryptionwrong key or corrupted dataif key mismatched
setBytes(key, bytes, ttlMs?) / getBytes(key) — save binary
const bytes = new Uint8Array([0, 1, 255, 42]);
store.setBytes('avatar', bytes); // sync backends: base64 {"__bin":true,"data":"b64"} + same TTL/encrypt
store.getBytes('avatar'); // → Uint8Array | undefined (null→undefined)
const db = new IndexedDb();
await db.setBytes('file', bytes, 60_000); // async, disk-backed, same envelope
await db.getBytes('file'); // → Uint8Array | undefinedbytes: Uint8Array(&[u8]in Rust) — base64 envelope__bin; string backends guardb64_len>4_800_000 → bytes too large for string storage, use IndexedDb(coversLocal/Session5MB → ~3.6MB binary).Cookiesalso hits3900guard first.getBytesreturnsundefinedif missing/expired; throwsvalue is not binary dataif you call it on aset-saved JSON key
remove(key) / clear() — delete
store.remove('user'); // validates key, broadcasts remove
store.clear(); // deletes only keys starting with this instance's prefix, broadcasts clearclearcollectsraw_keys().filter(startsWith(prefix))thenraw_removeper key (IndexedDB batch singleReadwritetxn)
has(key) / keys() / length() — inspect
store.has('user'); // → boolean via !isNull(get)
store.keys(); // → string[] stripped of prefix e.g. ['user','avatar']
store.length(); // → number counted under prefixpurgeExpired() — proactively evict
store.set('short', 'tmp', 40);
await new Promise(r => setTimeout(r, 100));
store.get('short'); // → null
// or sweep all:
store.purgeExpired();- Implemented as
for (k of stripPrefix(raw_keys)) get(k)— triggers lazy expiry per key.IndexedDbasync versionawaits eachget.
mset(entries, ttlMs?) / mget(keys) — bulk
store.mset({ a: 1, b: 2, c: 3 }, 5_000); // shared TTL
store.mget(['a','b','missing']); // → { a:1, b:2, missing: null } (sync) / Promise<object> (IndexedDb)
// validation: mset keys must be strings, mget keys must be strings + validate_key each; non-string → "mset keys must be strings"/"mget keys must be strings"enableEncryption(key) / generateKey() — AES-256-GCM
const s = new Local();
const key = s.generateKey(); // Uint8Array(32), also enables encryption for this instance
// bring your own
s.enableEncryption(my32Bytes); // throws "key must be exactly 32 bytes" if !=32
s.set('secret', { ssn: '000' }); // stored as hex(nonce 12B || ciphertext+tag 16B)
s.get('secret'); // wrong key → "decryption failed: wrong key or corrupted data" / "hex decode: …"/"utf-8 decode: …"
s.setBytes('enc', new Uint8Array([1,2,3])); // also encrypted (encrypts the __bin JSON)
// per-instance: enable right after new; keys zeroized on drop (ZeroizeOnDrop)- Uses
aes-gcmAes256Gcmgetrandomnonce per write; storedhexlength<28 → ciphertext too short - Client-side only — protects at-rest DevTools view, not live XSS with key in memory
subscribe(cb) → unsubscribe() — cross-tab sync
const s = new Local('app:');
const off = s.subscribe((action, key) => {
// action: "set" | "remove" | "clear", key: string ("" for clear)
console.log(action, key);
});
s.set('cart', [1,2,3]); // broadcasts {action:'set', prefix:'app:', key:'cart'} filtered by prefix
off(); // Closure::once_into_js → removes listener- Channel
hamd-sync-{kind}(local/session/memory/cookies/indexeddb) viaBroadcastChannel; fallback forlocal/sessionon Safari vialocalStorage __hamd_sync_{kind}storageevent with same{action,prefix,key,ts}and prefix filter.unsubscriberemovesmessageorstoragelistener.
IndexedDB async notes
const db = new IndexedDb('app:');
await db.set('k','v'); await db.get('k'); await db.has('k');
await db.keys(); await db.length(); await db.purgeExpired();
await db.mset({a:1}); await db.mget(['a']);
await db.setBytes('f', new Uint8Array([1])); await db.getBytes('f');
db.subscribe((a,k)=>{}); db.enableEncryption(key); db.generateKey();IndexedDbholds lazyIdbDatabase(hamd v1 kvstore) withcached_db,open_dbonupgradeneeded,IDBRequest→Promiseself-cleaning (onsuccess/onerrorcleared viaRc<RefCell>), batch deletes queued on singleReadwritetxn before anyawait.- Design invariant: no
Mutexheld acrossawait— lock→clone→drop→await (db().await,raw_setetc.)
Errors — what you will see
| Input | Error string |
| --- | --- |
| key === "" | key must be non-empty |
| key.length >256 | key too long: max 256 bytes |
| key contains \0/\n/\r | key contains invalid control characters |
| ttlMs NaN, Infinity, <=0 | ttlMs must be a positive finite number |
| enableEncryption len!=32 | key must be exactly 32 bytes |
| mset key not string | mset keys must be strings |
| mget key not string | mget keys must be strings |
| getBytes on JSON value | value is not binary data |
| get with wrong key | decryption failed: wrong key or corrupted data or hex decode: … |
| bytes too big for string storage | bytes too large for string storage, use IndexedDb |
| raw_set oversize Cookies | quota exceeded after evicting expired entries (after purgeExpired retry) |
| Cookies no HtmlDocument | no HtmlDocument |
Limits & quota
| Backend | Cap | Handling |
| --- | --- | --- |
| Local/Session | ~5MB (string, +33% base64 → ~3.6MB binary) | QuotaExceededError/code22 detected → purgeExpired then retry once |
| Cookies | 4KB per cookie (3900 guard) | encode_uri_component/decode_uri_component trim, Secure on https: |
| Memory | unbounded HashMap | no quota, SSR-safe |
| IndexedDB | disk ~50% (GBs) | async batch single txn, QuotaExceeded same retry |
Installation details
npm install @ahmedtrooper/hamd-wasm # npm @ahmedtrooper/[email protected] 87K tgz 7 files
# Rust
# Cargo.toml authors = ["Md. Ramjan Miah <[email protected]>"] homepage https://github.com/AhmedTrooper/hamd-wasm#readme exclude = ["pkg/","target/",".github/","web/"]Built with package.metadata.wasm-pack.profile.release wasm-opt -Oz --enable-bulk-memory/sign-ext/mutable-globals/nontrapping + profile.release opt-level z lto codegen-units1 panic abort strip. pkg/ is gitignored (/.gitignore /pkg/).
Architecture (source truth)
src/lib.rs impl_storage! Local/Session/Memory/Cookies sync + IndexedDb async (prefix, validate_key, encrypt, ttl, sync, bulk, bytes)
src/ops.rs StorageOps raw_set/get/remove/keys + StorageError QuotaExceeded
src/web.rs window Storage Local/Session, quota_error
src/cookie.rs HtmlDocument.cookie encode/decode 3900 Secure
src/memory.rs HashMap
src/idb.rs open_db v1 kv, raw_set/get/raw_remove single txn, get_all_keys, request_promise (Rc<RefCell> handlers)
src/crypto.rs Aes256Gcm 12B nonce hex, 28B min, zeroize
src/envelope.rs wrap Object{__val,__exp:Date.now()+ttl} →stringify / unwrap Expired
src/sync.rs BroadcastChannel hamd-sync-{kind} + storage fallback, prefix-filtered
tests/integration.rs 24 wasm-bindgen-test Chrome headless (TTL sleep, sync, encryption wrong-key, bytes, key validation)
web/ Vite 6 + Solid 1.9 docs site (supermodular routes: getting-started/storage/encryption/ttl/binary/sync/limits/api)
docs/ 7 feature md files (single-source, mirrored here)Development & release
cargo fmt --all
cargo clippy --target wasm32-unknown-unknown -- -D warnings
cargo check --target wasm32-unknown-unknown
cargo test --target wasm32-unknown-unknown --no-run
wasm-pack test --chrome --headless # 24 passed
wasm-pack build --target bundler --release --scope ahmedtrooper # pkg/ 188K wasm
cargo publish --dry-run # 25 files 99.5KiB
wasm-pack pack # @ahmedtrooper/hamd-wasm 0.1.0 87K
npm --prefix web run build # 20K js/5K cssCI .github/workflows/ci.yml: fmt/clippy/check/wasm-pack build/test + audit (cargo-audit) + coverage (cargo-llvm-cov). Release .github/workflows/release.yml: Validate → cargo publish ${{ secrets.CARGO_REGISTRY_TOKEN }} + wasm-pack build --scope ahmedtrooper → npm publish --access public ${{ secrets.NPM_TOKEN }} → GitHub Release on v* (VERSION=$(cargo metadata…); TAG=v$VERSION; git tag $TAG; git push origin $TAG). v0.1.0 → crates.io hamd-wasm 0.1.0 live, @ahmedtrooper/hamd-wasm 0.1.0 live (unscoped hamd-wasm blocked hash-wasm similarity).
License
MIT — LICENSE © 2026 Md. Ramjan Miah
