justerm-wasm-decode
v0.22.0
Published
WASM decoder for justerm's wire format: structure-of-arrays cell columns + format-owned colour/flag helpers.
Maintainers
Readme
justerm-wasm-decode
The canonical web decoder for justerm's binary wire
format — the engine's native decode compiled to WASM, so a web consumer shares one decoder with
the native backend instead of hand-writing (and re-syncing) a TypeScript mirror.
Typical data path: the native engine encodes a damage frame in your backend, the bytes cross your
IPC (e.g. a Tauri Channel), and in the webview this package decodes them into renderer-ready columns.
The decoder owns the fixed formats and standards (the wire records, the colour-ref encoding, the flag bit positions, the xterm 16–255 colour formula). Theme values (your 16 ANSI colours + default fg/bg) and render policy (inverse/dim/bold→bright, the font atlas, the cursor) stay yours.
Version-locked to the
justerm-corecrate: this package's version equals the engine version, so pinning onejusterm-coreversion gives you a matching encoder (native) + decoder (this).wireVersion()lets you assert agreement at load. (The engine crate was renamed fromjustermtojusterm-corein v0.6.0; the barejustermname on crates.io is a frozen tombstone.)
Install
npm install justerm-wasm-decodeUsage
import { decodeFrame, buildPalette, flags, wireVersion } from "justerm-wasm-decode";
import { resolveRgb, decodeColorRef, FG, BG } from "justerm-wasm-decode/colors.js";
// Bundler target (Vite/webpack): the above imports work directly.
// Web target (no bundler): `import init, { ... } from "justerm-wasm-decode"; await init();` first.
console.assert(wireVersion() === 18); // optional: assert the backend encoder agrees
// --- once at startup / on theme change ---
// buildPalette fills 0..15 from your scheme's ANSI colours and 16..255 from the
// fixed xterm cube/grayscale. Keep your default fg/bg alongside (they are the
// `Default` colour ref, resolved by role — not part of the 256).
const palette = {
colors: buildPalette(Uint32Array.from(scheme.ansi16)), // 16 × 0xRRGGBB
defaultFg: scheme.defaultFg, // 0xRRGGBB
defaultBg: scheme.defaultBg,
};
const F = flags(); // bit constants, read once
// --- per frame (e.g. an IPC message) ---
// Throws on a malformed buffer: a real `Error`, `message` = the DecodeError variant.
const frame = decodeFrame(wireBytes);
// Structure-of-arrays columns (zero-copy views) + the span directory.
const { codepoints, fg, bg, extra, link, spans } = frame;
const flagBits = frame.flags; // note: the column; `flags()` above is the constants
for (let s = 0; s < spans.length; s += 5) {
const line = spans[s], left = spans[s + 1], offset = spans[s + 3], count = spans[s + 4];
for (let k = 0; k < count; k++) {
const i = offset + k;
const col = left + k;
const fgRgb = resolveRgb(fg[i], palette, FG); // 0xRRGGBB
const bgRgb = resolveRgb(bg[i], palette, BG);
const bold = (flagBits[i] & F.bold) !== 0;
if (flagBits[i] & F.wide_char_spacer) continue; // trailing half of a wide glyph
// your adapter: map codepoints[i] -> atlas glyph, apply bold/inverse/dim,
// resolve extra[i]/link[i] via sideTable/linkTable, place at (line, col).
}
}
frame.free(); // release the column views — or scope with `using frame = decodeFrame(...)`Lifetime of the columns
codepoints / fg / bg / flags / extra / link / spans are zero-copy views into WASM
linear memory — the bulk data reaches JS with no per-cell boundary crossing. A view is invalidated
when WASM memory grows, which the next decodeFrame call can trigger. Read or copy what you need
from one frame before decoding the next, and free() the frame (or scope it with using) when done.
The palette from buildPalette is an owned copy, so it is safe to keep across frames.
What the columns hold
One entry per cell, in span order. spans is a flat directory: 5 u32s per span —
line, left, right, cell_offset, cell_count — where cell k of a span is column index
cell_offset + k.
| Column | Type | Meaning |
|--------|------|---------|
| codepoints | Uint32Array | base Unicode codepoint (not an atlas glyph id) |
| fg / bg | Uint32Array | colour references — pass to resolveRgb |
| underlineColor | Uint32Array | underline colour reference (SGR 58); 0 = follow the fg |
| flags | Uint16Array | attribute + layout bits — test with flags() constants |
| extra | Uint32Array | 1-based sideTable index for a grapheme cluster (0 = none) |
| link | Uint32Array | 1-based linkTable index for an OSC 8 hyperlink (0 = none) |
frame.sideTable (string[]) and frame.linkTable (string[]) carry the referenced clusters/URIs.
Cursor
The frame also carries the engine's cursor as scalar getters (screen coordinates, 0-based):
| Getter | Type | Meaning |
|--------|------|---------|
| cursorRow / cursorCol | number | cursor cell position |
| cursorVisible | boolean | false when the engine hides the cursor (DECTCEM ?25l) |
justerm reports the cursor; drawing it is your renderer's job. The family renderer
(justerm-renderer) draws it natively — hand it the position with setCursor and it paints the
caret as its own overlay. If your renderer has no cursor primitive, invert fg/bg on the cell at
(cursorRow, cursorCol) instead: a pure cursor move is included in the frame's damage spans (the old
and new cells), so an incremental cell-invert renderer clears the previous caret and inks the new
one without ghosting.
The frame carries two more cursor scalars the table above omits — cursorShape and cursorBlink.
Blink is a renderer-local animation, not an engine tick. cursorShape is the shape the application set
with DECSCUSR (0 block, 1 underline, 2 bar), or undefined while it has set none — draw your own
default shape then. CSI 0 SP q, DECSTR and RIS return it to undefined.
Colour helpers
resolveRgb(ref, palette, role) → 0xRRGGBB— resolves afg[i]/bg[i]ref:Default→ the role's default (FG/BG),Indexed→palette.colors[i],Rgb→ passthrough. Alloc-free; call it per cell. It does not apply inverse/dim/hidden/bold→bright — that is your render policy.buildPalette(ansi16) → Uint32Array(256)— the 256-colour table (0..15 your ANSI, 16..255 the fixed xterm standard). Build once per scheme.decodeColorRef(ref)—{ kind: "default" } | { kind: "indexed", index } | { kind: "rgb", r, g, b }. For inspection; allocates, so preferresolveRgbin the hot loop.
Flag constants (flags())
bold, dim, italic, underline, blink, inverse, hidden, strikethrough, wide_char,
wide_char_spacer, wrapline — each the bit to AND against a flags[i] value. How to act on them
(skip the spacer, bold→bright, dim) is your render policy. wrapline is engine reflow/copy metadata,
usually ignored by a renderer.
Underline style (underlineStyle())
underline above answers whether; underlineStyle(flags[i]) answers which, returning an
UnderlineStyle: None, Single, Double, Curly, Dotted or Dashed (SGR 4 : Ps, plus the
legacy SGR 21 on Double). It is a 3-bit field, not a flag, so there is no mask for it — pass
the whole flags[i] word and never shift by hand.
import { underlineStyle, UnderlineStyle } from "justerm-wasm-decode";
const style = underlineStyle(flagBits[i]);
if (style !== UnderlineStyle.None) drawUnderline(col, row, style); // your marks, your policyA cell that is not underlined reads as UnderlineStyle.None — None is a value of the style, so
you never have to infer it from a zero. flagBits[i] & F.underline and style !== None are the
same question: the engine derives the flag from the field, so they cannot disagree. Which is to say
a reader that only knows F.underline keeps working and draws a plain line — which is why carrying
the style did not move wireVersion().
Colour is separate and orthogonal: the underlineColor column carries it per cell, as a colour
reference you pass to resolveRgb like any other. A 0 there means the underline follows the
cell's fg, which is the common case.
License
Dual-licensed under MIT or
Apache-2.0, at your option —
same as the justerm-core crate.
