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

justerm-web

v0.18.0

Published

Browser terminal widget for the justerm engine: consumes decoded frames and paints them with justerm-renderer (WASM + WebGL2).

Readme

justerm-web

Browser terminal widget for the justerm engine. It consumes a DecodedFrame (structure-of-arrays cells + span directory, produced by justerm-wasm-decode) and paints it with the first-party justerm-renderer (WASM + WebGL2).

justerm-web is the consumer half of the family: the engine parses VT and produces frames, this widget renders them and turns user input into intent. It does no I/O — you feed it frames and it hands you back what the user did.

Install

npm install justerm-web

justerm-renderer and justerm-wasm-decode come along as dependencies. Both are wasm-bindgen modules, so a bundler needs WASM + top-level-await support (with Vite: vite-plugin-wasm + vite-plugin-top-level-await, and list both packages in optimizeDeps.exclude).

Usage

import { JustermRenderer, SelectionController, StubFrameSource, Terminal } from "justerm-web";
import type { CellGeometry } from "justerm-web";

// 1. The renderer owns the canvas. The theme is injected — justerm is theme-agnostic
//    and never guesses a colour (all values are packed 0xRRGGBB).
const renderer = await JustermRenderer.create({
  canvasSelector: "#term",
  fontFamily: "monospace",
  fontSize: 16,
  theme: {
    ansi: [
      0x000000, 0xcd0000, 0x00cd00, 0xcdcd00, 0x0000ee, 0xcd00cd, 0x00cdcd, 0xe5e5e5,
      0x7f7f7f, 0xff0000, 0x00ff00, 0xffff00, 0x5c5cff, 0xff00ff, 0x00ffff, 0xffffff,
    ],
    defaultFg: 0xcdd6f4,
    defaultBg: 0x1e1e2e,
    selectionBg: 0x45475a,
  },
});

// 2. A FrameSource supplies DecodedFrames. In production this is your IPC channel
//    (PTY -> engine -> wire -> decode); StubFrameSource drives it by hand.
const source = new StubFrameSource();

// Pixel -> cell is host policy too, so the widget asks for the geometry it needs.
// Every length is CSS px, because that is what `clientX`/`clientY` are — `renderer.cellSize()`
// is DEVICE px, so divide it by the ratio or the pointer lands on the wrong cell at dpr != 1.
// Return measured values: the cell must be positive and finite, the counts non-negative
// integers. A `0` or `NaN` cell makes every pointer event resolve to a garbage cell; the widget
// warns once per field rather than failing.
//
// Return `undefined` when there is nothing to measure — a `display: none` or detached container,
// or one the browser has not laid out yet. Do NOT skip this because the cell below stays
// positive: an absent box reports EVERY field as 0, including `originX`/`originY`, and a position
// of 0 is perfectly legal, so the widget cannot tell an absent box from a container in the corner
// of the window. You can. A gesture that outlives its container — `mousemove`/`mouseup` are
// window-scoped in every wiring, so a drag survives a tab switch or a collapsing panel — then
// resolves cells against the top-left of the window and auto-scrolls a pane nobody can see.
// `undefined` makes it request nothing; it does not cancel the gesture, so showing the container
// again under a held button resumes it.
const getGeometry = (): CellGeometry | undefined => {
  const r = canvas.getBoundingClientRect();
  if (r.width <= 0 || r.height <= 0) return undefined; // no box — not a box at (0, 0)
  const cell = renderer.cellSize(); // device px
  const dpr = window.devicePixelRatio || 1;
  return {
    originX: r.left,
    originY: r.top,
    cellWidth: cell.width / dpr,
    cellHeight: cell.height / dpr,
    cols,
    rows,
  };
};

// 3. The Terminal wires the two together and owns focus, input and selection.
//    `input` receives an *Intent* (a key press, paste, mouse report...), not bytes:
//    encoding intent for your backend is the host's job, not the widget's.
const term = new Terminal(source, renderer, {
  element: document.getElementById("term-container")!,
  input: { send: (intent) => myBackend.send(intent) },
  // The widget owns the pointer: a press goes to the application when it tracks the mouse, and to
  // this controller otherwise (Shift forces it local). Bind no mouse listeners of your own for it.
  selection: new SelectionController(mySelectionPort, getGeometry),
  // Links ride the same pointer: OSC 8 links come from the frames, and plain-text URLs from the
  // logical line your backend answers for the hovered row (core `viewport_logical_lines`).
  // Opening one is yours — the widget never opens anything.
  links: {
    onActivate: (uri) => myShell.open(uri),
    port: { lineAt: (row) => myBackend.logicalLineAt(row) },
  },
  getGeometry,
});

Terminal takes more options (scroll, events, clipboard, and beforeKey for claiming an app shortcut before it reaches the shell) — each is an injected seam rather than a built-in policy, so the host stays in control of transport, clipboard and theme. See the demo for a fully wired example.

Several terminals on one canvas

A browser caps live WebGL contexts at around sixteen, so one context per terminal puts a ceiling on how many you can show and makes every re-attach re-bake a glyph atlas. TerminalSurface removes both: one canvas, one context, N terminals drawn as viewports on it.

JustermRenderer.create composes a surface for you, so a single-terminal app never names one. A host that wants several opens the surface itself and attaches to it:

import { JustermRenderer, observeViewportRect, TerminalSurface } from "justerm-web";

const surface = await TerminalSurface.open("#surface");
// The host sizes the shared drawing buffer, in DEVICE px, because it belongs to no one terminal.
surface.resizeSurface(cssWidth * devicePixelRatio, cssHeight * devicePixelRatio);

const left = await JustermRenderer.attach(surface, { fontFamily: "monospace", fontSize: 16, theme });
const right = await JustermRenderer.attach(surface, { fontFamily: "monospace", fontSize: 28, theme });

// Keep each terminal's GL viewport following its DOM overlay — a scroll, a layout change or a pane
// drag moves the overlay without moving anything WebGL can see. Returns a disposer.
// `undefined` means the overlay has no box at all — a hidden pane. It is NOT an origin of (0, 0).
const follow = (term: JustermRenderer) => (origin: { x: number; y: number } | undefined) =>
  origin ? term.setViewportRect(origin.x, origin.y) : term.hide();
const stopLeft = observeViewportRect(leftOverlayEl, canvasEl, follow(left));
const stopRight = observeViewportRect(rightOverlayEl, canvasEl, follow(right));
left.resize(cssWidth / 2, cssHeight);
right.resize(cssWidth / 2, cssHeight);

surface.requestRender(); // coalesces every terminal's request into one present per frame

Two consequences are forced by WebGL binding one context to one canvas, and both are yours to handle:

  • Every terminal shares one stacking plane. Each widget is a transparent DOM overlay over its viewport rect, so you cannot interleave arbitrary DOM between two terminals.

  • The overlay must track its rect. observeViewportRect does it for you — a ResizeObserver on the overlay and the canvas, plus a capture-phase scroll listener, because a ResizeObserver fires when a box changes and never when an element moves. Drive setViewportRect yourself instead if you already know when your layout moves; what you must not do is neither, because a missed update is silent — the GL viewport stays where it was while the overlay moves off it.

  • A hidden pane is set aside, not destroyed. observeViewportRect hands your callback undefined when the overlay has no box, and JustermRenderer.hide() is what you wire it to. Hiding keeps the grid registered, its cells resident and its font configuration's atlas alive, so coming back is a setViewportRect and costs no atlas bake — which surface.bakes() lets you check. dispose() is the other thing: it is end of life, and rebuilding afterwards is exactly the cost a shared surface exists to remove.

    Hiding the DOM overlay is not by itself hiding the terminal, and the difference is measurable. The pixels are on the shared canvas, not in your overlay, so what actually sets a pane aside is the box going away — display: none removes it, observeViewportRect fires, and your undefined branch runs. Wire that branch: without it a removed box reads back as the origin (0, 0), and the grid is re-placed there at full size, over its sibling.

    So the rule is "only a hide that zeroes the box hides the terminal", and it is not a list of exceptions. Every guard in this package keys on the same predicate (width <= 0 || height <= 0), which is what makes the rule stable while a list of CSS idioms would not be. Measured on the two-pane demo, reading the pixel where pane B is drawn: display: none clears it; content- visibility: hidden leaves it painted, unchanged. visibility: hidden, a closed <details> ancestor, opacity: 0 and an element covered by another all keep a full-size box too, so they all leave the terminal fully drawn and fully paid for. [hidden], a closed <dialog> and a detached node do zero the box and work like display: none.

    If you hide by any route that keeps the box, call hide() yourself — the box is a truthful measurement there, so nothing can infer your intent from it.

    Fitting a hidden pane is safe. display: none makes the box 0x0, and both sizing paths — proposeDimensions and the resize a widget does for you — answer nothing to propose for a box with no area, exactly as they do for a NaN one. So a FitController that keeps observing a hidden pane simply stops proposing, and the grid it had is the grid it comes back with. A box that is measured and merely tiny still floors to the 2x1 minimum, which is the case that minimum exists for.

  • A density change invalidates every device-px number you gave — including the rects. Register surface.onDensityChange, and from it re-supply both the surface size and every terminal's origin:

    import { viewportOrigin } from "justerm-web";
    
    surface.onDensityChange((dpr) => {
      surface.resizeSurface(cssWidth * dpr, cssHeight * dpr);
      for (const [term, overlayEl] of panes) {
        const origin = viewportOrigin(
          { overlay: overlayEl.getBoundingClientRect(), canvas: canvasEl.getBoundingClientRect() },
          dpr,
        );
        if (!origin) continue; // a hidden pane has no box to re-scale; it stays hidden
        term.setViewportRect(origin.x, origin.y);
        term.resize(paneCssWidth, paneCssHeight); // the cell moved, so the grid may too
      }
    });

    observeViewportRect will not do the rects for you. It computes at the live ratio, but nothing re-runs it on a density change: its three triggers are a ResizeObserver on the overlay and the canvas, a capture-phase scroll, and one call at setup. A ResizeObserver on the default box reports CSS pixels, and a density change moves no CSS box — so it stays silent, and the last origin it sent is left scaled by the old ratio. On a monitor switch a pane at CSS x=500 then draws at half its offset, over its left sibling, with no error and every pixel plausible.

    It also fires after a GL context restore that adopted a density which moved while the context was dead — the one density adoption with no call from anyone behind it. A notification arriving during a loss is dropped rather than queued, so the renderer re-reads the live ratio when it rebuilds; a sole tenant then repairs itself, because it derives its own drawing buffer, and a shared one cannot, because the buffer and every rect are yours. Same handler, same obligation — you do not have to distinguish the two cases.

    You do not owe a frame here. resizeSurface re-creates the drawing buffer, which clears it, so the widget presents once on the next animation frame after your handler returns — coalesced, so re-placing ten terminals in one handler still costs one. Push a frame anyway if your engine needs to learn the new grid; what you must not have to do is push one merely to get the pixels back.

Each terminal keeps its own font, palette, selection, cursor and decorations; two on the same font configuration share one glyph atlas, and the last one to leave releases it.

A runnable version of everything above is demo/shared-surface.html — two terminals at two font sizes on one canvas, with the page showing through the buffer between them. pnpm demo, then open /shared-surface.html.

Tearing down

Terminal.dispose() is end of life, not unmount. It stops consuming frames, detaches the listeners it attached, and disposes the renderer you handed it — so nothing the widget started can still draw. Build a new Terminal (and a new renderer) rather than mounting a disposed one; it throws if you try.

term.dispose(); // also disposes `renderer`

It also stops the context-loss notification below, so a disposed widget never calls back into your handler — the same thing xterm.js does by clearing its pending restore timeout on dispose.

On a shared surface, disposal releases only that terminal. A renderer built with JustermRenderer.create owns the surface it created and ends it, exactly as before. One built with JustermRenderer.attach does not: it hands back its own grid and leaves the surface — the canvas, the context, the density tracking and context-loss recovery — running for its siblings. One sentence covers both: a layer ends what it exclusively holds, and never what it shares.

Tearing the whole surface down: your terminals first, then the surface.

for (const term of terminals) term.dispose(); // each hands back its own grid
surface.dispose();                            // then the canvas, the loop, the watcher

surface.dispose() does not end your Terminal widgets, and the order above is not a style preference. The surface holds grids, not widgets: it ends what each terminal registered with it, which is the renderer, and it never saw the Terminal you constructed — the same sentence as above, applied to itself. Measured on a two-terminal page: calling surface.dispose() alone leaves every widget mounted with its hidden textarea still in the DOM and still subscribed to your frame source, so the next frame your backend pushes throws no grid with id N. Disposing the terminals first leaves nothing behind (2 textareas → 0, surface.gridCount already 0, no throw from either call).

Three things disposal does not cover, so they are yours:

  • Your Terminal widgets, per the paragraph above, whenever you end the surface rather than the terminal.
  • Anything you constructed and kept — a Scrollbar, the resize observer returned by observeResize, the accessibility controllers. The widget never saw them, so it cannot end them.
  • GPU memory. Disposing stops the renderer's work; the wasm instance, its GL context and glyph atlas live until you drop your own reference and let the page collect it.

Surviving a lost GL context

A browser may destroy a WebGL context at any moment — GPU reset, driver eviction, a backgrounded tab — and every GL object goes with it. You do not have to do anything about it. The renderer rebuilds itself when the browser fires webglcontextrestored, keeping the terminal's content, because that content lives on the CPU side and never left.

What has no other signal is the context that does not come back. It leaves a blank canvas with nothing to distinguish it from a quiet terminal, so the widget will tell you:

const renderer = await JustermRenderer.create({
  canvasSelector: "#term",
  fontFamily: "monospace",
  fontSize: 16,
  theme,
  contextRestoreTimeout: 3000, // ms; this is the default, xterm.js's value
  onContextLoss: () => showBanner("The GPU dropped this terminal. Reload to recover."),
});

onContextLoss fires at most once per loss, and only if the context is still gone when the deadline passes. Treat it as a warning rather than a verdict: Chromium keeps re-attempting a real restore roughly once a second indefinitely, so the context may still come back afterwards and the terminal will repaint by itself. What to do meanwhile is yours — dim the terminal, show a message, or tear the widget down and fall back (VSCode swaps in a DOM renderer at this point).

To ask instead of being told — polling a status line, or attaching after a loss already happened:

renderer.isContextLost();    // has a loss been REPORTED to us
renderer.isRestoreOverdue(); // …and did it miss its deadline

A re-fit during a loss settles after recovery

A resize() that lands while the context is lost is provisional. The renderer commits the grid you asked for but defers reading the drawing buffer back — a dead context answers 0, and adopting that would shrink the terminal to one cell — so any clamp the browser applies settles later, inside the first render() after recovery.

On a sole-tenant canvas you do not have to do anything about it. The widget listens for webglcontextrestored, and on that event it renders (which is what settles the clamp), re-derives the drawing buffer from the grid it is holding, and re-writes the display box from what was actually granted. Both the grid and the box heal without a call from you.

On a shared surface the second of those is yours, and you are told when it is owed. A terminal that shares a canvas does not size the buffer — nothing below you can, since a buffer holding N grids in M font configurations has no cell to be a multiple of — so the re-derivation above is the sole tenant's alone. What a shared host owes is the same thing it owes on any density change, and it arrives through the same channel: onDensityChange fires after a restore that adopted a new ratio. If the density did not move, nothing is owed and nothing fires.

What the clamp looks like when it settles — asking for 4000 columns during a loss (MAX_TEXTURE_SIZE 8192, 9px cell):

| | grid | display box | |---|---|---| | during the loss | 4000 cols | 36000px | | after recovery | 910 cols | 36000px ← now 8190px |

The browser stretched an 8190px buffer across a 36000px box. terminalSize() reports the truth throughout, and is the value to drive your engine from:

// after any resize, drive the engine from what was adopted rather than what you asked for
const { cols, rows } = renderer.terminalSize();

Most consumers never reach this: it needs a requested grid larger than the browser's buffer limits and a re-fit landing inside the loss window.

Which question isContextLost() answers

isContextLost() answers "was I told", not "is the GPU usable right now", and the difference is real rather than pedantic: a browser destroys a context synchronously and only queues the event, so for a short window this returns false while every GL call is already dead. It is the honest thing to show a user and the wrong thing to gate drawing on — which is why the renderer guards its own work on a stricter predicate it does not export.

What it does and does not do

Does: renders frames, resolves the injected theme, tracks selection and search highlights, exposes a screen-reader mirror and an accessible view, turns pointer/keyboard events into intent.

Does not: read a PTY, own a transport, pick colours, or run the terminal engine. Those are the host's — that boundary is why the engine stays independently testable.

Links

Building blocks

The widget is assembled from injected seams rather than built-in policy, so each of these is something you supply, replace or drive:

  • FrameSource — where DecodedFrames come from. In production this is your IPC channel; StubFrameSource drives it by hand in tests and demos.
  • Renderer — the small interface the widget paints through. JustermRenderer is the real adapter over justerm-renderer (WASM + WebGL2); supplying a fake covers the widget's wiring with no GL context.
  • Terminal — wires a FrameSource to a Renderer and owns focus, input and selection.
  • CellMirror — a viewport-sized text mirror of the screen. It applies each frame's scroll op so the screen-reader row tree stays correct across scroll, and serves row text, the column map, and each cell's OSC 8 URI so a link stays whole across frames that repaint only part of it. It holds no colour: resolving and compositing colour happens in the renderer's WASM.
  • SelectionController, SearchController, LinkController, ClipboardController, AccessibilityController, Scrollbar, FitController — each drives one behaviour through a port you implement, so transport, clipboard and theme stay yours.

Licence

MIT OR Apache-2.0.