kevy4.0

kevy 上 WebAssembly

kevy 在浏览器里是一个真正的 store,不是编译期玩具:npm 包 @goliajp/kevy 把引擎(KV + TTL + 计数器 + 扫描 + pub/sub)编译到 wasm32-unknown-unknown,配一个手写的 ES module loader,持久化落 OPFS(IndexedDB 兜底),pub/sub 能跨 tab。同一批 crate 也能编译到 wasm32-wasip1,所以 Rust API 在 wasmtime / wasmer 和边缘运行时里同样可用。

在线体验:kevy.golia.jp 的 demo 就是跑在这个模块上的浏览器 REPL——命令行、重载不丢的 OPFS 持久化、跨 tab 的 pub/sub,全程没有后端。

快速开始(浏览器)

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——包括同源的其他 tab:
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();                  // 耐久性屏障

写入以 kevy append-only 日志的形式流进存储,下次以同一个 persist.name 调用 open() 时重放。整个包共六个文件(打包约 165 KB):wasm 模块、loader、OPFS worker、手写的 TypeScript 类型,加上常规的 README 和 manifest。边界两侧都是零依赖。

Loader API

open(options) 实例化模块、重放已存日志(开了持久化时),并启动 tick 定时器与跨 tab 桥。选项:

选项默认值含义
wasmloader 旁边的 kevy.wasm模块来源:URL、ArrayBufferUint8ArrayResponse,或已编译的 WebAssembly.Module
persistfalse(纯内存){ name, backend }:每个 name 一条日志;backend = "auto"(OPFS,IndexedDB 兜底)、"opfs""idb"
broadcasttrueBroadcastChannel 的跨 tab pub/sub 桥
namepersist.name"kevy"实例名;同时圈定存储文件与广播频道的作用域
tickMs100TTL 清扫 + 事件轮询节奏;0 关掉定时器——自己调 tick()

返回的 Kevy 句柄(完整签名见 kevy.d.ts):

方法语义
set(key, value, { ttlMs? })SET,可选带过期
get(key) / getText(key)GET,返回 Uint8Array / UTF-8 文本;不存在或已过期时为 undefined
del(key) / exists(key)DEL / EXISTS
expire(key, ttlMs) / persist(key) / pttl(key)PEXPIRE / PERSIST / PTTL(-1 无 TTL,-2 无 key)
incrby(key, delta?)INCRBY,返回新值
dbsize() / flushall()DBSIZE / FLUSHALL
keys(pattern?, limit?)KEYS,Redis glob,可选上限
tick()手动执行一次 TTL 清扫 + 事件轮询;返回过期 key 数
subscribe(channel, cb) / psubscribe(pattern, cb)SUBSCRIBE / PSUBSCRIBE;都返回退订函数
publish(channel, payload)PUBLISH 给本 tab 订阅者,且(桥开着时)广播到其他同源 tab;返回本地接收者数
flush()耐久性屏障:把待写帧刷进存储
compact()把存储重写为在线键空间的紧凑镜像
close()flush、拆掉桥/定时器/存储、释放实例

key 和 value 处处接受 string | Uint8Array | ArrayBuffer:字符串在边界处做 UTF-8 编码,字节视图直接透传——value 是真正的二进制,不是字符串。

绑定的工作方式(没有 wasm-bindgen)

kevy 的零依赖法则延伸到工具链:边界两侧都没有绑定生成器。kevy-wasm crate 导出一套扁平的手写 C ABI——30 个 extern "C" symbol;pkg/kevy.js 是手写 loader,负责 TypedArray 边界、UTF-8 编解码、持久化泵和跨 tab 桥。约定如下:

导出面分四组:core(open/close/alloc/free/clock/tick/结果缓冲区)、KV(kevy_setkevy_getkevy_delkevy_existskevy_expirekevy_persistkevy_pttlkevy_incrbykevy_dbsizekevy_flushallkevy_keys……)、pub/sub(kevy_subscribekevy_psubscribekevy_unsubscribekevy_publishkevy_poll_events)和 AOF 泵(kevy_aof_frames_outkevy_aof_frame_inkevy_aof_dump)。随包的 loader 若不合你的宿主,ABI 就是受支持的集成点——crate 文档写明了每个 symbol 和打包字节格式。

持久化:一条真日志,与 native kevy 字节同格

浏览器没有文件系统,耐久性由宿主中介:开了持久化后,每笔写同时编码出与磁盘上 kevy AOF 相同的 RESP multi-bulk 帧(kevy-persist 格式——persistence.md)。loader 每个微任务把待写帧泵进存储一次,所以同步的一串写只花一次存储 append。await db.flush() 是耐久性屏障;flush() resolve 意味着后端已刷到磁盘。

浏览器 tab 写出的日志能原样在 native kevy 里重放——同一个 magic 头、同样的帧。把 .aof 从 OPFS 拷出来、指给 native embedded store(或服务器),键空间就回来了。反向同样成立:入站泵接受 native 写的日志。损坏的尾巴遵循 native 重放契约——完好前缀被应用、尾巴被丢弃,下一次 compaction 从在线状态重写存储。

两个后端,backend: "auto" 下自动挑选:

Compaction 是自动的:追加日志一旦超过 max(512 KB, 上一镜像的 4 倍),loader 就把存储重写为在线键空间的紧凑镜像(浏览器侧的 AOF rewrite)。compact() 可在快照或导出前强制执行一次。

localStorage 有意不做后端:约 5 MB 的配额、每次写都阻塞主线程的同步 API、只能存 UTF-16 字符串——作为写日志完全不合格。

跨 tab pub/sub

publish() 经引擎投递给同 tab 订阅者,并且(桥开着时)把帧经每实例一个的 BroadcastChannel 广播给所有其他同源 tab,由各 tab 的本地订阅者接收。投递是 at-most-once、无 backlog——只有当下开着的 tab 能收到,后开的 tab 不会补收。这与 kevy 服务器 pub/sub 的契约相同(pubsub.md),照一个面写的代码在另一个面上行为一致。

时钟、tick 与 TTL

wasm32-unknown-unknown 没有线程也没有 OS 时钟,引擎因此用手动 TTL 清扫器 + 宿主喂入的时钟运行:loader 在每个入口调用前把 Date.now() 经 ABI 喂进去,并按定时器(默认 100 ms)跑 tick()。后果:

性能

完整方法论、环境与原始数字见 bench/WASM-BENCH.md(headless Chrome 驱动,3 轮取中位,16 字节 value,1k 与 100k 条两档)。对上 Web 应用实际拥有的那些存储:

轴(n=100k)kevy-wasmvs IndexedDBvs localStorage
点读(ops/s)1.67 M77×0.48×
点写(ops/s)1.79 M189×4.9×
批量载入(ms)60.8快 36×快 4.5×
扫描,约 10% 命中(ms)5.6快 46×快 5.7×
耐久写(ops/s)785 k(OPFS)12.6–17.4×约 2×
重启到可用(ms)129快 2.7×更慢(见下)

头条与诚实的 caveat:

Rust 面:WASI、边缘运行时、直接嵌入

引擎本身——kevy-embedded 及其依赖闭包(kevy-storekevy-persistkevy-hashkevy-byteskevy-mapkevy-resp)——两个 wasm target 都能编译;CI 在每次 push 上把整张清单加 kevy-wasm 一起 gate。网络 reactor 三件套(kevy-rtkevy-syskevy-uring)有意留在闭包之外,wasm 构建因此保持干净。

Target命令说明
wasm32-unknown-unknowncargo build -p kevy-wasm --target wasm32-unknown-unknown --release浏览器工件(kevy_wasm.wasm);npm loader 实例化它。自己的宿主直接驱动 C ABI。
wasm32-unknown-unknown(Rust API)cargo build -p kevy-embedded --target wasm32-unknown-unknown无线程、无 OS 时钟:用 Config::with_ttl_reaper_manual() 打开,喂 set_clock_ns / set_wall_clock_ms,宿主循环里调 Store::tick()
wasm32-wasip1cargo build -p kevy-embedded --target wasm32-wasip1Instant / SystemTime 可用——不用喂时钟。std::fs 对 preopen 目录可用,所以 Config::with_persist("/data") + wasmtime --dir=/data 给出真 AOF 耐久性。线程仍缺席:继续用手动清扫器。

wasm32-unknown-unknown 上用 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();

错误是 KevyResult / KevyError,argv 风格的写方法接受借用切片,与 native 完全一致——见 kevy-embedded 文档。

Cloudflare Workers 之类的边缘 isolate 照浏览器配方:每 isolate 一个实例,Date.now() 当时钟源,tick() 惰性调或挂 scheduled handler。要跨 isolate 重启的耐久性,就在你的 handler 里把写镜像到平台的持久存储(AOF 泵会把帧交给你);isolate 内部,kevy 是热的内存层。

FAQ

完整命令面在浏览器里都可用吗?npm 包暴露的是 KV + TTL + 计数器 + 扫描 + pub/sub 这一刀——浏览器 store 需要的那部分,刻意保持小巧(wasm 模块未压缩约 425 KB)。wasm target 上的 Rust API 则暴露你编译进来的 kevy-embedded feature 的全部能力。

持久化的数据可移植吗?可以——它就是标准 kevy AOF。浏览器 → native 和 native → 浏览器都能重放。格式契约见 persistence.md

共享内存线程(+atomics)呢?随包模块是单线程的,与所有浏览器类 target 匹配。宿主提供线程的地方引擎是线程安全的,但受支持的路径仍是手动 tick() 模型。

为什么不用 SharedWorker 而用 BroadcastChannel?单一 owner 的 SharedWorker 拓扑理论上更干净,但可用面更窄(Android Chrome 上明显缺席);BroadcastChannel 桥以同一实测延迟档的兼容性胜出。

为什么不用 wasm-bindgen?kevy 的服务器、embedded、浏览器三面都是零第三方依赖。绑定生成器是一个构建期依赖,还会拥有边界布局的所有权;手写 ABI 让契约保持显式、带版本、可审计。