npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@dbalmain/vici

v0.1.0

Published

A headless vi editing core: modes, motions, operators, counts, text objects, visual mode, undo, dot-repeat and macros, with no view attached.

Readme

vici.js

A headless vi editing core for JavaScript. Modes, motions, operators, counts, text objects, visual mode, undo, dot-repeat, macros, marks and surround — with no view attached.

import { Editor } from "vici.js";

const editor = new Editor("select id, name\nfrom users");
editor.typeKeys("cwSELECT<Esc>");
editor.text(); // 'SELECT id, name\nfrom users'

This is a second implementation of the Rust core in the same repository, not a binding to it. The Rust crate is the behavioural oracle: this engine passes all 411 of its fixture cases, rendering the same state block character for character, and a differential fuzzer keeps it that way.

Why not WASM

A WASM build of the Rust core is a fine thing to ship, and this package is not an argument against it. It is an argument that a host which already runs JavaScript should not pay to cross a boundary for an editing core this small. The comparison below is against the sibling beetle experiment's TypeScript engine, which implements the same contract; its WASM column is in that project's own report.

Design

Three decisions account for most of the difference from a straightforward port.

The buffer stores UTF-8 bytes. The contract is byte offsets — every cursor, edit, mark and register position is a UTF-8 byte offset, because that is the shape tree_sitter::InputEdit wants. An engine that stores a JavaScript string must convert on every offset it hands out, on every keystroke, forever. Storing UTF-8 in a Uint8Array gap buffer makes the offsets be the storage, and turns the row index, word motions and search into byte arithmetic. The cost lands on text(), which decodes — so hosts should read it when they need to render, not in a loop.

ASCII is a fast path, not an assumption. The buffer keeps a count of non-ASCII bytes. While it is zero, grapheme columns are subtraction; when it is not, only the rows that actually contain wide text pay for segmentation. The engine stays correct for 🇦🇺 and combining marks either way — those cases are in the fixture suite.

Intl.Segmenter is built on first need. Constructing one costs tens of milliseconds of ICU startup, which for most editors is the largest single item in their time-to-first-keystroke. An all-ASCII buffer never constructs one.

Two smaller ones: a key is its canonical vi spelling ("a", "<C-r>", "<Esc>"), so the keymap is a Map probe and dot-repeat storage is a join(''); and the row index carries a pending shift from a pivot, so an edit does not rewrite every row after it.

Results

Node v24.18.1, AMD Ryzen 9 9955HX, linux/x64. Both engines in one process, same workloads, same harness; p50 of bulk typeKeys. Full tables, including per-key dispatch and p95, are in reports/bench.md.

| Workload | vici.js | beetle vici-js | | | ------------------------------------- | ---------: | ----------------: | ---- | | cold start (fresh process) | 10 ms | 92 ms | 9× | | insert-1k — type 1 000 characters | 737 µs | 8.41 ms | 11× | | words-small10w10b3dw, 1 KiB | 14 µs | 25 µs | 1.8× | | words-100k50w50b, 100 KiB | 9.9 µs | 33 µs | 3.3× | | words-1m50w50b, 1 MiB | 14 µs | 35 µs | 2.5× | | delete-word — 200 × dw, 100 KiB | 428 µs | 4.03 ms | 9.4× | | undo-storm — 100 inserts, 100 undos | 305 µs | 1.66 ms | 5.4× | | macroqa~jq200@a | 391 µs | 815 µs | 2.1× | | search/needle<CR>nnn, 100 KiB | 382 µs | 1.15 ms | 3.0× | | operator-allggdG, 100 KiB | 28 µs | 7.7 µs | 0.3× | | edit-session — mixed script, 19 KiB | 112 µs | 1.85 ms | 17× |

the sibling project's published cold-start figure, measured the same way on the same machine; the others are measured here, side by side.

operator-all is the honest loss, and it is the trade-off working as designed: deleting a whole buffer is where a JavaScript string wins, because V8 makes slice and substring nearly free, while a byte buffer has to copy what it displaced. Everything that touches offsets per keystroke goes the other way.

For scale, the same project measured its Rust-in-WASM build at 244 µs on edit-session and 27 ms on search; this engine is faster than that WASM build on both.

Size

esbuild --bundle --minify --format=esm, then brotli — what a browser downloads. Same pipeline for both. See reports/size.md.

| Artifact | raw | gzip | brotli | | -------------- | -----------: | -----------: | -----------: | | vici.js | 40.9 KiB | 13.2 KiB | 12.0 KiB | | beetle vici-js | 47.9 KiB | 14.3 KiB | 12.9 KiB |

Staying in step with the Rust core

Two mechanisms, both cheap to run.

The fixture oracle. crates/vici/tests/fixtures/editor.vici holds 411 cases and tests/snapshots/ holds the state each one produces. npm test runs every case through this engine and compares the rendered block — text, cursor, mode, selection, register, history depth, jumps, marks, pending keys, last change, recording, and the full effect stream. Nothing is skipped and there is no allowlist, so a divergence in any observable fails the suite.

The differential fuzzer. npm run fuzz generates buffers and keystroke scripts, runs them through a WASM build of the Rust core (the sibling beetle artefact) and this engine in the same process, and diffs the same blocks. Grapheme-cluster traps and regex-shaped search patterns are out of scope; the generator hunts for differences in how editing commands are interpreted.

npm run fuzz -- --cases 8000 --seed 7
npm run fuzz -- --campaign soup --until
npm run fuzz -- --oracle rust --cases 200   # native `vici-oracle`, needs cargo

Campaigns (--campaign, or all of them by default): a hand-picked corpus, a cartesian grid of edge buffers × scripts, random key-soup, structured operator/visual/surround/mark/indent/search families, and fixture mutations. Failing cases are shrunk to the shortest disagreeing key prefix and written in editor.vici format, so they paste straight into the fixture file. The generator is seeded, so a reported seed reproduces a run exactly.

Both mechanisms read the same block from the same code: vici::fixtures holds one parser and one renderer, used by the crate's snapshot test and by vici-oracle. There is no second Rust definition of the format to drift from.

Running it

npm install
npm test           # 411 oracle cases + layer tests, no dependencies at runtime
npm run check      # typecheck the JSDoc types
npm run types      # emit types/*.d.ts
npm run bench      # speed, to stdout
npm run size       # bundle size, to stdout
npm run fuzz       # WASM vs JS differential fuzz (needs beetle's wasm build)

npm run bench and npm run size accept --vs <entry> to measure another engine that exposes createEngine(text) or an Editor class through the same typeKeys / handleKey interface.

API

const editor = new Editor(text); // or createEditor(text)
editor.handleKey("<C-r>"); // → Effect[]
editor.handleKeys(["d", "w"]);
editor.typeKeys("2d3w"); // vi notation

editor.text(); // decode the buffer
editor.cursor; // byte offset
editor.cursorPoint(); // { row, col }, col in bytes
editor.mode; // NORMAL | INSERT | REPLACE | VISUAL | VISUAL_LINE
editor.selection(); // [start, end] | null
editor.register.text; // decoded on demand
editor.mark("a"); // byte offset | null
editor.setViewport({ topRow, height }); // whenever the host renders or resizes
editor.setIndent({ shiftWidth, tabWidth, useTabs });
editor.setText(text);
editor.jumpTo(offset); // host-side navigation, clamped

Effects are the things the core cannot do itself. Everything else — cursor, mode, selection — is queryable, so it is not duplicated into the stream:

{
  type: "edit", edit;
} // startByte/oldEndByte/newEndByte + points
{
  type: "mode", mode;
}
{
  type: "scroll", scroll;
}
{
  type: "prompt";
} // `:`
{
  type: "bell";
}
{
  type: "recordingStarted", register;
}
{
  type: "recordingStopped", register;
}

edit converts field for field into tree_sitter.InputEdit, which is the whole reason the coordinates are bytes.

Known divergences

None are reachable from the fixture suite or from 5 000 fuzzed cases, but they are where to look first if one turns up.

  • Unicode tables. Segmentation uses Intl.Segmenter where Rust uses unicode-segmentation; character classes use Unicode property escapes (\p{Alphabetic}, \p{White_Space}, \p{Uppercase}) where Rust uses its own tables. A Unicode version skew between the JavaScript engine and the Rust crate could show up on code points neither suite exercises.
  • Case mapping. recase maps one code point at a time with toUpperCase/toLowerCase, which matches Rust's char::to_uppercase / to_lowercase including the one-to-many forms (ßSS). Applying them to a single code point also avoids JavaScript's context-sensitive final-sigma rule, which Rust's per-character mapping does not have.
  • Search. The smartcase policy is identical and matching is literal, as in the Rust core. An ASCII pattern over a buffer that also contains non-ASCII takes a byte path that folds ASCII case only, so a pattern of k will not match a Kelvin sign the way the general path would.

Next

  • Publishing: the package is vici.js on npm; a first-party WASM entry point can land later. The fuzzer currently loads the sibling beetle WASM artefact.

License

MIT OR Apache-2.0, at your option.