# ntk sends canvas commands to the X server instead of sending pixels

> A Node.js UI toolkit for X11 that wraps node-x11 with web-shaped APIs, implements the context2d surface through XRender, shapes text in JavaScript and paces frames against a server round trip. Nothing compiles at install, and the Node requirement is stated three different ways.

**sidorares/ntk** — node.js desktop UI toolkit

- Repository: https://github.com/sidorares/ntk
- Website: https://sidorares.github.io/ntk/
- Stars: 91 · Forks: 13
- Language: JavaScript
- License: not declared
- Published: 2026-08-23 · Updated: 2026-08-23 · Language: en
- Canonical page: https://hysenlabs.com/projects/sidorares-ntk

## The Node requirement is stated three different ways

Three numbers, three files, and they do not agree.

The installation section says the package requires Node.js 20.19 or newer and an X server. The manifest in `package.json` declares `"engines": { "node": ">=18.19.0" }`, which admits a runtime two major versions below the documented floor. And the resource management section says server-side resources support `using` and `await using` on Node 24 and later, so a documented feature of the current release needs a third major version above the manifest floor.

In practice the sequence is: follow the page and you are on at least 20.19, use `await using` for deterministic server-side cleanup and you need 24, and treat the `engines` field as a claim nobody has reconciled with the rest. That matters most on install, because a strict `engine-strict` setting will install 18.19 and then fail somewhere less obvious than the version check.

The X server requirement is the other half of the floor, and it is the harder one to fake, because there is now a way around it that the page explains in a single sentence further down.

## npm install ntk, and nothing is compiled

The install is one line:

```bash
npm install ntk
```

What follows it is the sentence that distinguishes this package from every native X11 binding: everything, including font rasterization, is pure JavaScript, so `npm install` never compiles anything. There is no node-gyp step, no system library to satisfy first, no prebuilt binary to match your architecture.

That claim is what makes the rest of the design possible. If rasterization is JavaScript, then the X server can be JavaScript too, and it is. The playground at the documentation site runs ordinary ntk code in your browser against node-x11's in-browser pure-JS X server, XRender included, with bundled fonts. The same server runs headless in Node, which means ntk applications and their tests can run with no real X server at all.

The basic window takes four statements:

```js
import { createClient } from 'ntk';

const app = await createClient();
const wnd = app.createWindow({ width: 500, height: 300, title: 'Hello' });
wnd.on('mousedown', (ev) => wnd.setTitle(`click: ${ev.x},${ev.y}`));
wnd.map();
```

Window creation, a DOM-style event subscription, and `map()` to make it visible. That is the same shape a web developer expects, which is the stated point of the wrapper layer.

## Text is shaped in JavaScript and rasterized by a built-in scanline rasterizer

This is the part of the project with the most work behind it, and the most surprising claim in it: a line of text costs about a byte per glyph on the wire.

The pipeline is, in order. OpenType shaping, kerning, ligatures and complex scripts through fontkit. Bidirectional text through bidi-js. Automatic font fallback when a family does not cover a run. All of that in pure JavaScript, then a built-in scanline rasterizer, then the result cached server-side as XRender glyphs. Because the server caches the glyph, the client sends a reference rather than a bitmap for each repetition, and one byte per glyph is the figure the page gives for the wire cost.

Font names resolve through fontconfig with `fc-match`, so a request for a family name follows the system's substitution rules rather than a private lookup.

One deliberate exception: very large and continuously animated text renders as server-side trapezoids instead of cached bitmaps, since a cached bitmap at that size is both expensive to transfer and wrong on the next frame. A separate `TextLayout` engine wraps styled text to a target width, which is line breaking rather than glyph generation, and the page is careful to separate the two.

## Your JavaScript issues commands; the X server composites the pixels

Every window or pixmap can create a 2d canvas implementing the HTML context2d API, delivered through the XRender extension. The surface is the familiar one: paths with arcs, beziers and `Path2D` accepting SVG path data, non-zero and even-odd fill rules, transforms through `save()` and `restore()`, clipping, `globalAlpha`, and Porter-Duff composite operations.

The point is where the work happens. Image composition, scaling, blur, text composition and gradients are all performed on the X server side, so the process you run sends operations and the server does the pixels. That is the trade: your loop stays cheap and the display keeps up, and a busy X server becomes a shared resource that slows every client on it.

Images split across the boundary. PNG and JPEG decode client-side through `loadImage` and composite server-side through `ctx.drawImage`, and `SvgView` renders static SVG shapes, gradients, transforms and `use` through the same pipeline. If a canvas has a great many drawing calls, the page suggests the opposite route: pass a node-canvas canvas to `drawImage` as the source, draw locally, and transfer pixels once.

The boundary of the project is stated as plainly as the mechanism. Rendering markdown, formulas or rich text is not ntk's job, because it draws and a document is a tree of layout decisions above it. That work lives in `@react-x11/components`, over the react-x11 renderer.

## Coalescing plus a per-frame round trip is what makes a forwarded display usable

The networking story is the strongest argument for the toolkit, and it is a two-part mechanism.

First, noisy events are coalesced into paced frames. `resize`, `mousemove` and `expose` are the ones named. The latest state wins and nothing queues up, so a fast mouse cannot build a backlog of positions that arrive after you stopped moving.

Second, each frame is fenced with a server round trip. That is what turns coalescing into throughput adaptation: rendering automatically slows to the connection's real rate instead of drawing a trail of stale updates over an ssh-forwarded display. Animation is DOM-style:

```js
function frame(now) {
  // ... draw ...
  wnd.requestAnimationFrame(frame); // ~60fps locally, RTT-paced remotely
}
wnd.requestAnimationFrame(frame);
```

The comment in the example states the two regimes directly, roughly 60 frames per second locally and round-trip-paced remotely.

Four knobs govern it and are documented in `docs/window.md`: `frameInterval`, `frameSync`, `maxFramesInFlight` and `coalesceEvents`. That last one can be turned off, and the page points at the raw uncoalesced event stream for when you want every event rather than the latest state. Coalescing is a policy with a switch, not a hard-wired behaviour.

## Eleven entry points so a colour parser does not drag in fontkit

By default `ntk` is the whole package. The X11 client, the font engine, the SVG parser and the image decoders all load together, which is the wrong shape for a program that only wants the colour parser. So the manifest exports eleven subpaths and the page maps what each one pulls in.

Four of them carry a dependency. `ntk/image` brings `jpeg-js` and `pngjs`. `ntk/svg` brings `htmlparser2` and `domutils`. `ntk/font` brings `fontkit`. `ntk/color` brings `parse-color`. `ntk/xembed`, which is the XEmbed protocol implementation, brings `x11`.

The other six bring nothing at all. `ntk/imagedata` gives `ImageData`, `pixelLayout` and `toStraightRgba`. `ntk/path` gives `Path2D` and `parseSvgPath`. `ntk/gl` gives `GLError`, `GL_MODES` and `DEFAULT_GL_POLICY`. `ntk/shadow-math` gives `shadowSigma`, `shadowReach` and `blurScale`, and `ntk/shadow-tiles` gives `planShadowTiles` and `shadowTileAlpha`, which is shadow blur split into planning and alpha evaluation.

The design guarantee underneath it is the sentence worth remembering: each subpath is the same module the root re-exports, so importing a name both ways gives you the same function or class, and nothing is loaded or instantiated twice. A module-graph property like that is easy to claim and easy to break, so check it if you mix import styles.

## The default 3d backend is indirect GLX, which many systems disable outright

3d is not one backend but two, chosen by a setting called `glPolicy`, and the default is the one most systems switch off.

The default is indirect GLX. It covers most of the OpenGL 1.4 API and is serialized into the X connection. The page's warning is direct: on many systems indirect GLX is disabled by default, and you have to enable it for GL to work at all. So a first run of any 3d example on a stock Linux desktop can fail before a line of your code executes, and the fix lives in X server configuration rather than in the package.

The opt-in alternative is direct GL, which is shader GL on the real GPU with no pixels on the socket. On Linux it is OpenGL ES 2 over DRI3 plus Present. On macOS with XQuartz it is CGL over the Apple-DRI extension. Either way it needs the optional `x11-dri` addon, which the manifest lists as an optional dependency with a floor of 0.5.0 and a ceiling below 1.

That is the whole design space: slower but dependency-free and widely disabled, or fast and off by default. The examples directory carries both sides of it, `glclock.js`, `gles-triangle.js` and `glxpixmap.js`, which is a reasonable way to decide before you commit.

## The published tarball is lib/ only, and three versions shipped inside a day

Packaging is minimal and worth reading before you depend on it. The `files` array contains one entry, `lib`, so the published package contains nothing but the library. No examples, no tests, no documentation, no website. The package is ESM only, with `"type": "module"`, a main entry at `./lib/index.js`, and the eleven subpaths in the exports map.

Dependencies are pinned by caret and are all pure JavaScript: fontkit, htmlparser2, jpeg-js, pngjs, parse-color, bidi-js, domutils, plus `linebreak` and `extrude-polyline`, with `x11` as the transport. The test script sets a colour strictness flag before running the built-in test runner, `NTK_STRICT_COLORS=1 node --test`, which suggests colour handling is the area where approximations hide.

Release cadence is fast. v8.17.3 was published on 2026-09-30, v8.17.4 later the same day, and v8.17.5 on 2026-10-01, with the manifest at 8.17.5 and the last push on 2026-09-28. That comes from a conventional-commits pipeline with a release-please configuration, a release-please manifest and a `check-release-message` script, which enforces a message format on the release commit.

One licensing loose end. The manifest declares MIT, so npm shows an MIT licence, but there is no LICENSE file in the repository tree. If your process needs the licence text, ask for it.

## Conclusion

ntk fits a Node developer who needs a desktop window with a canvas on Linux, or who has to develop UI over an ssh-forwarded display where a naive event stream turns into a trail of stale frames. The frame coalescing and the per-frame server fence are the parts worth stealing even if you build on something else. It does not fit a web project, a Windows or Wayland-only desktop, or anyone who wants Electron: this is an X11 toolkit with no browser involved, and the MIT declaration lives in `package.json` while the repository tree carries no LICENSE file. Verify three things before you commit: which Node version you target, since the page says 20.19, the manifest says 18.19.0 and `await using` needs 24; whether indirect GLX is enabled on the machines you target, since the default 3d backend is disabled on many systems; and whether your application needs document layout, which this project explicitly leaves to `@react-x11/components`.

## FAQ

### What Node.js version does ntk require?

The page says Node 20.19 or newer and an X server, while the manifest declares node >=18.19.0, and the resource management section says using and await using need Node 24 or later. Follow the page at minimum and use Node 24 if you want await using.

### Does installing ntk compile anything?

No. Everything including font rasterization is pure JavaScript, so npm install ntk never runs a compiler. There is no node-gyp step and no system library to satisfy before installing.

### Can ntk run without a real X server?

Yes. node-x11 ships an in-browser pure-JS X server with XRender and bundled fonts, which the documentation playground uses, and the same server runs headless in Node. See docs/xserver.md for the headless path.

### Why does 3d graphics in ntk not work on my system?

The default backend is indirect GLX, which is disabled by default on many systems, so GL does not work until you enable it. The opt-in alternative is direct GL, which needs the optional x11-dri addon, and the backend is selected by the glPolicy setting.

### How does ntk behave over an ssh-forwarded display?

Noisy events such as resize, mousemove and expose are coalesced into paced frames where the latest state wins, and each frame is fenced with a server round trip, so rendering slows to the connection's real throughput. The knobs are frameInterval, frameSync, maxFramesInFlight and coalesceEvents.

## Sources

- [Official documentation](https://sidorares.github.io/ntk/)
- [Official README](https://github.com/sidorares/ntk#readme)
- [Project repository](https://github.com/sidorares/ntk)
- [Release notes](https://github.com/sidorares/ntk/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/sidorares-ntk
