kevy
本页内容

kevy 上 WebAssembly

kevy 在浏览器里是一个真正的 store,不是编译期玩具:npm 包 @goliapkg/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 @goliapkg/kevy
import { open } from "@goliapkg/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() 时重放。4.0 起日志说带校验和的 v2 记录格式(KEVYAOF2——见 persistence.md):存储字节里的位翻转在重放时被拒绝,而不是静默应用。4.0 之前的 tab 存下的日志照常重放(v1,永久可读),并在首次 compaction 时升格 v2;从浏览器 tab 泵出的日志依旧能在原生 kevy 里原样重放,反之亦然。整个包共六个文件(打包 496 KB,过网络 gzip 后 481 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 桥。约定如下:

  • 实例kevy_open 发出的 u32 句柄;其余每个调用都以句柄开头。0 永远不是合法句柄。
  • 入参字节(ptr, len) 对传入,指向由 kevy_alloc 取得、用 kevy_free 归还的线性内存。
  • 出参字节落在每实例的结果缓冲区里,经 kevy_out_ptr / kevy_out_len 读取,在同一句柄的下一次调用前有效——调用方应立即拷出。
  • 状态码>= 0 成功,-1 操作错误(结果缓冲区里有 UTF-8 消息),-2 句柄非法。
  • 数字f64 过界(时钟、TTL、计数——全部落在 2^53 精确整数范围内)。
  • kevy_abi_version() 报告 ABI 契约版本,loader 可据此拒绝不匹配的模块。

导出面分四组: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" 下自动挑选:

  • OPFS(首选):由一个小型专用 worker 持有的 FileSystemSyncAccessHandle——同步句柄只在 worker 里可用。每次 append 都先 flush 再确认。
  • IndexedDB(兜底):同一个泵打到 object store 上,服务没有 OPFS 同步句柄的环境(主要是旧版 Safari)。

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()。后果:

  • TTL 精度跟随 tick 节奏:500 ms TTL 的 key 在期限后的第一个 tick 过期。在意就把 tickMs 调紧,或 tickMs: 0 完全自己掌控节奏。
  • 两次调用之间不发生任何后台工作——没有隐藏开销;但 tickMs: 0 下忘了调 tick(),过期 key 会一直活着、事件队列不排空。

性能

完整方法论、环境与原始数字见 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:

  • 对 IndexedDB 每条轴都是数量级碾压——点读 77–86×,点写跨数据集档位 166–189×。
  • 耐久写是裸 IndexedDB 的 12.6–17.4×:泵按微任务批量写帧,一串写只花一次存储 append。它同时还比 localStorage 发完就忘的 setItem 快约 2 倍——而且是一条真正的 append 日志。
  • localStorage 点读是 kevy 唯一不赢的一列(0.42–0.48×),bench 里完整拆解了为什么任何 wasm 宿主的 store 都赢不了:Chrome 的 localStorage 读是渲染进程内存里的哈希查找(实测 3.5–5 M ops/s,属于裸 Map 级别,不是存储级别),而每个 wasm store 每次调用都要付 UTF-8 编码 + 边界穿越 + 从线性内存拷出——约 2–3 M ops/s 的天花板。kevy 在 loader 的 staging 优化(常驻暂存缓冲、encodeInto、缓存的 memory 视图——比朴素 loader 提升 2.2×)之后贴着这个天花板跑(1.7–2.1 M)。kevy 赢下的是 localStorage 在任何速度下都做不到的一切:二进制 value、TTL、计数器、扫描、大于 5 MB 的数据集、pub/sub、以及不阻塞的持久化。
  • 跨 tab RTT 在 64 KB 载荷上胜过 storage-event hack(p50 0.295 ms vs 0.335 ms),1 KB 以下与约 0.105 ms 的任务跳底线打平——而 storage-event hack 在慢消费者下会丢帧、只能传字符串、每次 ping 都写进磁盘配额。

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 是热的内存层。

在 Tauri 应用里

在桌面 / 移动应用里跑 kevy?Tauri 的后端是 Rust,所以 kevy 可以原生地嵌在后端(一份共享的 store,持久,跨窗口),而不是以 wasm 的形式嵌在 webview 里。后端 store 与 wasm 之间怎么选,见 docs/tauri.md

FAQ

完整命令面在浏览器里都可用吗?大部分可用。模块按浏览器能承载的全部 feature 构建——corepersistindextextvector——所以 cmd 除了 KV、TTL、计数器、扫描与 pub/sub,还能到达 IDX.*(二级索引、全文、向量检索)、VIEW.*TABLE.*。2026-08 之前它只有较小的那一刀,项目自己的首页因此在一个没编进索引的构建上演示二级索引。

哪个已发布版本有:npm 上 5.1.0 及更早带的是较小的那一刀 —— 在那里 IDX.*VIEW.*TABLE.* 会答 unknown command。更宽的构建在主分支上、也在 kevy.golia.jp 上,随下一个已发布版本到达 npm。想更早拿到就从源码构建(cargo build -p kevy-wasm --target wasm32-unknown-unknown --release)。

被留在外面的东西缺的不是字节,而是浏览器给不了的东西:replicate 要网络对端,listener 要 TCP socket,tier 要磁盘目录。流、事务、geo 与脚本在任何平台上都不在嵌入式引擎的动词面里——边界是 ESTORE_OPS manifest,不是这个构建。

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 让契约保持显式、带版本、可审计。