kevy4.0

kevy on WebAssembly

kevy runs in the browser as a real store, not a compile-time curiosity: the npm package @goliajp/kevy ships the engine (KV + TTL + counters + scans + pub/sub) compiled to wasm32-unknown-unknown behind a hand-written ES-module loader, with durable persistence over OPFS (IndexedDB fallback) and pub/sub that crosses tabs. The same crates also build for wasm32-wasip1, so the Rust API works under wasmtime / wasmer and in edge runtimes.

Try it live: the kevy.golia.jp demo is a browser REPL over exactly this module — commands, OPFS persistence that survives reloads, and pub/sub across tabs, with no backend.

Quick start (browser)

npm install @goliajp/kevy
import { open } from "@goliajp/kevy";

const db = await open({ persist: { name: "app" } });

db.set("greeting", "hello");
db.set("session", "abc123", { ttlMs: 60_000 });
db.getText("greeting");            // "hello"
db.incrby("visits");               // 1, 2, 3, ...
db.keys("user:*");

// Pub/sub — including other tabs of the same origin:
const off = db.subscribe("events", (payload, channel) => {
  console.log(channel, new TextDecoder().decode(payload));
});
db.publish("events", "hi from this or any other tab");

await db.flush();                  // durability barrier

Writes stream to storage as a kevy append-only log and replay on the next open() with the same persist.name. The package is six files (~165 KB packed): the wasm module, the loader, the OPFS worker, hand-written TypeScript typings, and the usual README + manifest. Zero dependencies on both sides of the boundary.

The loader API

open(options) instantiates the module, replays the stored log (when persistence is on), and starts the tick timer and the cross-tab bridge. Options:

OptionDefaultMeaning
wasmkevy.wasm next to the loaderModule source: URL, ArrayBuffer, Uint8Array, Response, or a compiled WebAssembly.Module
persistfalse (in-memory){ name, backend }: one log per name; backend = "auto" (OPFS, IndexedDB fallback), "opfs", or "idb"
broadcasttrueCross-tab pub/sub bridge over BroadcastChannel
namepersist.name or "kevy"Instance name; scopes both the storage file and the broadcast channel
tickMs100TTL sweep + event poll cadence; 0 disables the timer — call tick() yourself

The returned Kevy handle (full signatures in kevy.d.ts):

MethodSemantics
set(key, value, { ttlMs? })SET, optionally with an expiry
get(key) / getText(key)GET as Uint8Array / as UTF-8 text; undefined when absent or expired
del(key) / exists(key)DEL / EXISTS
expire(key, ttlMs) / persist(key) / pttl(key)PEXPIRE / PERSIST / PTTL (-1 no TTL, -2 no key)
incrby(key, delta?)INCRBY, returns the new value
dbsize() / flushall()DBSIZE / FLUSHALL
keys(pattern?, limit?)KEYS with a Redis glob, optionally capped
tick()One manual TTL sweep + event poll; returns the expired-key count
subscribe(channel, cb) / psubscribe(pattern, cb)SUBSCRIBE / PSUBSCRIBE; both return an unsubscribe function
publish(channel, payload)PUBLISH to local subscribers and (bridge on) other same-origin tabs; returns the local receiver count
flush()Durability barrier: pending write frames are flushed to storage
compact()Rewrite storage as a compacted image of the live keyspace
close()Flush, tear down bridge/timer/storage, free the instance

Keys and values are string | Uint8Array | ArrayBuffer everywhere: strings are UTF-8 encoded at the boundary, byte views pass through — values are genuinely binary, not strings.

How the binding works (no wasm-bindgen)

kevy's zero-dependency law extends to the toolchain: there is no binding generator on either side. The kevy-wasm crate exports a flat, hand-written C ABI — 30 extern "C" symbols — and pkg/kevy.js is a hand-written loader that owns the TypedArray boundary, the UTF-8 codecs, the persistence pump, and the cross-tab bridge. The conventions:

The export surface groups into core (open/close/alloc/free/clock/ tick/result buffer), KV (kevy_set, kevy_get, kevy_del, kevy_exists, kevy_expire, kevy_persist, kevy_pttl, kevy_incrby, kevy_dbsize, kevy_flushall, kevy_keys, …), pub/sub (kevy_subscribe, kevy_psubscribe, kevy_unsubscribe, kevy_publish, kevy_poll_events), and the AOF pump (kevy_aof_frames_out, kevy_aof_frame_in, kevy_aof_dump). If the shipped loader doesn't fit your host, the ABI is the supported integration point — the crate docs specify every symbol and packed byte format.

Persistence: a real log, byte-compatible with native kevy

The browser has no filesystem, so durability is host-mediated: with persistence on, every write also encodes the same RESP multi-bulk frame a kevy AOF stores on disk (the kevy-persist format — docs/persistence.md). The loader pumps pending frames to storage once per microtask, so a synchronous burst of writes costs one storage append. await db.flush() is the durability barrier; a resolved flush() means the backend has flushed to disk.

The log a browser tab writes replays in a native kevy unchanged — same magic header, same frames. Copy the .aof out of OPFS and point a native embedded store (or the server) at it, and the keyspace comes back. The reverse holds too: the inbound pump accepts a native-written log. Corrupt tails follow the native replay contract — the intact prefix is applied, the tail is dropped, and the next compaction rewrites storage from live state.

Two backends, picked automatically under backend: "auto":

Compaction is automatic: once the appended log outgrows max(512 KB, 4× the last image), the loader rewrites storage as a compacted image of the live keyspace (the browser-side equivalent of an AOF rewrite). compact() forces one before a snapshot or export.

localStorage is deliberately not a backend: a ~5 MB quota, a synchronous API that blocks the main thread on every write, and UTF-16 string-only storage disqualify it as a write log.

Cross-tab pub/sub

publish() delivers to subscribers in the same tab through the engine, and (bridge on) broadcasts the frame over a per-instance BroadcastChannel to every other same-origin tab, where each tab's local subscribers receive it. Delivery is at-most-once with no backlog — only currently-open tabs receive a message, and nothing is replayed to a tab that opens later. That is the same contract as kevy server pub/sub (docs/pubsub.md), so code written against one face behaves on the other.

Clocks, ticks, and TTLs

wasm32-unknown-unknown has no threads and no OS clock, so the engine runs with the manual TTL reaper and a host-fed clock: the loader feeds Date.now() through the ABI before each entry point and runs tick() on a timer (default 100 ms). Consequences:

Performance

Full methodology, environment, and raw numbers: bench/WASM-BENCH.md (headless-Chrome harness, median of 3 runs, 16-byte values at 1k and 100k keys). Against the storage a web app actually has:

Axis (n=100k)kevy-wasmvs IndexedDBvs localStorage
point read (ops/s)1.67 M77×0.48×
point write (ops/s)1.79 M189×4.9×
batch load (ms)60.836× faster4.5× faster
scan, ~10% match (ms)5.646× faster5.7× faster
durable write (ops/s)785 k (OPFS)12.6–17.4×~2×
restart to usable (ms)1292.7× fasterslower (see below)

Headlines and the honest caveats:

The Rust face: WASI, edge runtimes, direct embedding

The engine itself — kevy-embedded and its dependency closure (kevy-store, kevy-persist, kevy-hash, kevy-bytes, kevy-map, kevy-resp) — builds for both wasm targets; CI gates the whole list plus kevy-wasm on every push. The network reactor crates (kevy-rt, kevy-sys, kevy-uring) are deliberately outside the closure, so the wasm build stays clean.

TargetCommandNotes
wasm32-unknown-unknowncargo build -p kevy-wasm --target wasm32-unknown-unknown --releaseThe browser artifact (kevy_wasm.wasm); the npm loader instantiates it. For your own host, drive the C ABI directly.
wasm32-unknown-unknown (Rust API)cargo build -p kevy-embedded --target wasm32-unknown-unknownNo threads, no OS clock: open with Config::with_ttl_reaper_manual(), feed set_clock_ns / set_wall_clock_ms, call Store::tick() from the host loop.
wasm32-wasip1cargo build -p kevy-embedded --target wasm32-wasip1Instant / SystemTime work — no clock feeding. std::fs works against preopened dirs, so Config::with_persist("/data") + wasmtime --dir=/data gives real AOF durability. Threads still absent: keep the manual reaper.

Direct embedding on wasm32-unknown-unknown in Rust:

use kevy_embedded::{Config, Store, set_clock_ns, set_wall_clock_ms};

let store = Store::open(Config::default().with_ttl_reaper_manual())?;

// Host feeds the clock before time-sensitive work…
set_clock_ns(now_ms_from_host().saturating_mul(1_000_000));
set_wall_clock_ms(now_ms_from_host());

store.set(b"hello", b"world")?;
let v = store.get(b"hello")?;   // Some(b"world".to_vec())

// …and drives expiry at its own cadence:
let _stats = store.tick();

Errors are KevyResult / KevyError and argv-style write methods take borrowed slices, exactly as on native — see the kevy-embedded docs.

Cloudflare Workers and similar edge isolates follow the browser recipe: one instance per isolate, Date.now() as the clock source, tick() lazily or from a scheduled handler. For durability across isolate restarts, mirror writes to the platform's durable store from your handler (the AOF pump gives you the frames); inside the isolate kevy is the hot in-memory tier.

FAQ

Does the full command surface work in the browser? The npm package exposes the KV + TTL + counter + scan + pub/sub cut — the surface a browser store needs, kept small on purpose (the wasm module is 416 KB uncompressed). The Rust API on wasm targets exposes the full kevy-embedded feature set of whichever features you compile in.

Is the persisted data portable? Yes — it is a standard kevy AOF. Browser → native and native → browser both replay. See docs/persistence.md for the format contract.

What about shared-memory threads (+atomics)? The shipped module is single-threaded, which matches every browser-style target. The engine is thread-safe where the host provides threads, but the manual tick() model remains the supported path.

SharedWorker instead of BroadcastChannel? A single-owner SharedWorker topology is cleaner in theory but its availability is narrower (notably absent on Android Chrome); the BroadcastChannel bridge won on compatibility with the same measured latency class.

Why not wasm-bindgen? kevy ships zero third-party dependencies — server, embedded, and browser alike. A binding generator would be a build-time dependency and would own the boundary layout; the hand-written ABI keeps the contract explicit, versioned, and auditable.