# skia-canvas makes every build shell out to gh and npm before it starts

> skia-canvas is a Node canvas renderer built on Skia through a Rust native module. Its Makefile reads the latest release tag from the GitHub CLI and the published version from the registry while it is still parsing, its version bump step uses macOS sed syntax, and its release target diffs with Jujutsu rather than git.

**samizdatco/skia-canvas** — A multi-threaded, GPU-powered, 2D vector graphics environment for Node.js

- Repository: https://github.com/samizdatco/skia-canvas
- Website: https://skia-canvas.org
- Stars: 2,602 · Forks: 104
- Language: JavaScript
- License: MIT
- Published: 2026-09-28 · Updated: 2026-09-28 · Language: en
- Canonical page: https://hysenlabs.com/projects/samizdatco-skia-canvas

## Every make target reaches the network while the Makefile is still parsing

Two variables at the top of the Makefile are computed with `$(shell ...)` rather than being set by the user. One runs `gh release list --limit 1 --json tagName,isDraft` to read the newest tag, and the other runs `npm view skia-canvas version` to read what is published. Because make expands variables as it reads the file, both commands execute before any target runs, which means `make clean` needs an authenticated GitHub CLI, a reachable registry and working credentials. There is no offline mode and no conditional around them. The default goal is the native library itself, so the ordinary path is:

```make
$(LIB): $(NPM) $(LIB_SRC)
	@npm run build -- dev
	@touch $(LIB)
```

with `npm ci --ignore-scripts` run first, which means the install script is skipped and the native build is driven explicitly by make instead.

## The version bump uses BSD sed and the release target diffs with jj

The `bump` target takes a version argument, asks for confirmation on stdin, then runs `npm version`, a `sed` in-place edit against Cargo.toml, and `cargo update -p skia-canvas`. The edit is written as `sed -i '' -e ...`, which is BSD syntax and means the suffix argument is mandatory; GNU sed expects `-i` to take the suffix directly and errors on the empty string. The same target uses `[[ ]]` conditionals and `/bin/echo -n`, both of which assume bash and a shell whose echo supports the flag. The `release` target is more surprising still: it checks `jj diff --from main --to @ package.json`, so releasing assumes Jujutsu is installed and configured, even though the repository itself is hosted on GitHub.

## The container image tag defaults to the month you build in

`CONTAINER_VERSION ?= $(shell date +%Y.%m)` gives the container image its tag, evaluated at parse time and overridable from the command line. Two builds a month apart therefore produce differently tagged images with nothing in the source changing, which is the sort of default that makes a deployed image impossible to trace back to a commit. The rest of the versioning story is split across two files that must agree: `package.json` and `Cargo.toml` both carry the version, and the bump target edits both in one step, replacing the first `version = ` line in the Cargo file. Both currently read 4.0.0-rc8, while the three newest tags are 4.0.0-rc8, rc7 and rc6, published on three consecutive days.

## Two font crates are version-locked to what another dependency uses

The Cargo file carries comments that are effectively build instructions. `read-fonts` is pinned to 0.39 with the note that it must be the same version hayro uses, and `write-fonts` is pinned with an equals sign to 0.48.1 with the note that it must be the sibling release to read-fonts. The first is a soft constraint that a routine `cargo update` will violate without warning, and the second is a hard pin that will conflict rather than move. Between them they cover variable font support, which the feature list advertises directly. A third coupling is written as a comment on the TIFF dependency: it is pulled with default features turned off because it exists only to wrap ImageData pixels in an uncompressed, ICC-tagged container for the Sharp handoff, and none of its compression codecs are wanted.

## Graphics backend choice is a compile-time feature, not a runtime setting

Four features decide what the native module can talk to. `metal` pulls the Apple backend and, with it, a chain of objc2 crates for the core foundation, Quartz Core and Core Graphics layers. `vulkan` pulls the Vulkan backend plus `ash` and `vulkano` and a window-handle feature. `window` is the native windowing layer and brings in winit, a sleep helper, DRM and a core-video crate. `freetype` is separate and switches on font rendering in the underlying binding. Both `metal` and `vulkan` enable the ganesh feature of the Skia binding, so the GPU path is compiled in rather than detected at run time. The crate is built as a `cdylib` only, so it produces a loadable module and not a Rust library another crate could link.

## The exports map lists the type entry after the browser one

The manifest declares `main` as `./lib/index.js` and then an `exports` map with four conditions: `node.import` to `./lib/index.mjs`, `node.require` to `./lib/index.js`, `browser` to `./lib/browser.js` and `types` to `./lib/index.d.ts`. Condition order is what decides which entry a resolver takes when several match, and `types` is listed last here, after `browser`. For an editor or bundler that walks the conditions in order and stops at the first hit, a browser build would win before the declaration file is considered. The published file list is `lib/*js`, `lib/*ts`, `lib/classes/*js` and `lib/fonts`, so the declaration file is shipped and it is the ordering that decides whether it is found.

## Volta pins Node 24, the floor says 18, and the types are for Node 26

Three different Node versions appear in the same manifest. `engines.node` is `>=18`, the Volta block pins `24.18.0`, and the development dependency on Node type definitions is at `^26.6.3`. The runtime floor is therefore the permissive one, the tool the project develops against is much newer, and the type definitions describe a version newer still. The npm dependency list is short by design, three runtime packages: `detect-libc` to work out which platform to fetch a binary for, and `follow-redirects` and `https-proxy-agent` so the prebuilt download works behind a redirect and a proxy. One Rust dependency is vendored into the tree at `vendor/simplecss`.

## Four example blocks end in the middle of a call

The examples on the front page are cut off, and the cuts are all in the same place: partway through a call, with the arguments unclosed. The first ends at `await canvas.toFile("rainbox.png",` with nothing after the comma. The multi-page example ends with a comment that stops partway through the word multi-page. The wide-gamut example ends at `ctx.fillRect(pad,` and the Sharp integration example ends inside a destructuring pattern. The shape of each example is still readable, since the imports, the canvas construction and the drawing calls are all present, but the parts a reader would need to make them run are the parts that are not there. Each is paired with an output image in `docs/assets/examples/`, so what the finished result looks like is visible even where the code is not complete. The page's own navigation also mixes schemes, linking the getting started, documentation and release notes pages over plain http while the banner image and the discussion forum link use https.

## Conclusion

skia-canvas is the right pick when you need Canvas semantics on a server with no browser in the picture, and the extension surface, wide-gamut colour, multi-page output and font control go well past what a browser canvas offers. Two things to weigh first. Everything is moving: the manifest and the Cargo file both sit at 4.0.0-rc8, and the three most recent tags are consecutive release candidates published on consecutive days, so pin an exact version instead of a range. And the build is not self-contained. Running any make target reaches the network through the GitHub CLI and the npm registry, the container tag defaults to the month you happen to build in, and the version bump recipe is written for macOS. The last push on the default branch is 2026-09-24.

## FAQ

### Does skia-canvas need a browser to render?

No. It is described as a headless vector graphics renderer and windowing toolkit that lets you use the Canvas API without a browser, powered by Skia, the GPU-accelerated graphics engine used by Google Chrome. The same package can also open a native OS window, so one dependency covers both server-side output and on-screen drawing.

### How do I install skia-canvas?

`npm install skia-canvas` on a supported platform. The manifest has a build step that either compiles the native module or downloads a prebuilt one, with `node lib/prebuild.mjs download --or-compile` as the download route and `npm run build` as the compile route. Node 18 or newer is required.

### Which graphics backends does skia-canvas support?

Backend choice is a compile-time Cargo feature rather than a runtime setting. `metal`, `vulkan`, `window` and `freetype` are declared as features in Cargo.toml, and both metal and vulkan turn on the ganesh feature of the Skia binding. The crate is built as a cdylib, so it loads as a native module.

## Sources

- [License: MIT](https://github.com/samizdatco/skia-canvas/blob/main/LICENSE)
- [Project website](https://skia-canvas.org)
- [README](https://github.com/samizdatco/skia-canvas/blob/main/README.md)
- [Releases](https://github.com/samizdatco/skia-canvas/releases)
- [samizdatco/skia-canvas on GitHub](https://github.com/samizdatco/skia-canvas)

---

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