Platform Support
GJSify has two independent axes, and they are easy to confuse:
- The runtime axis — GJS, Node.js, Bun, Deno, the browser, NativeScript. This is
what Runtimes describes, and what each package declares in
package.json#gjsify.runtimes. - The operating system axis — Linux, macOS, Windows. A package can be perfectly cross-runtime and still be Linux-only, because the native bridge underneath it only ever gets built for Linux.
This page is about the second axis. It exists because the first one used to be the only one anybody tracked, and that is how a set of Linux-only native bridges ended up underneath a project that described itself as platform-independent.
The short answer
Section titled “The short answer”| Linux | macOS | Windows | |
|---|---|---|---|
Apps on GJS (--app gjs) | supported | partial — see below | not available (no GJS host) |
Apps on Node/Bun/Deno (--app node, via @gjsify/node-gi) | supported | supported | supported |
Browser builds (--app browser) | supported | supported | supported |
The Node-free toolchain (gjsify build under GJS) | supported | not yet | not available |
Browser builds carry no native bridge at all, so they are portable by construction. The GJS side is where the operating system matters.
Why Windows has no GJS host
Section titled “Why Windows has no GJS host”@gjsify/node-gi runs on Windows because it links the portable
GObject-Introspection stack, which gvsbuild ships. Anything that needs GJS
itself does not, because there is no prebuilt libgjs for Windows: GNOME’s own
gjs CI is Linux-only, and gjs must be source-built against a SpiderMonkey that
Windows package managers do not provide in a form its build system consumes.
This is a genuine upstream blocker, not a missing task. It is tracked in
status/open-todos.md with the exact conditions that would unblock it.
Working on gjsify FROM Windows
Section titled “Working on gjsify FROM Windows”No GJS host does not mean no development host. Under Node, a Windows machine
runs the toolchain: gjsify install, gjsify run build:infra, gjsify build
(--app node, --app browser, and --library), gjsify check, gjsify clear,
gjsify copy, the conformance audit (node scripts/audit-runtimes.mjs --check),
the install backend with its .cmd/.ps1/sh bin shims, and @gjsify/cli’s
own test suite. All of that is verified on win32 x64 / Node 24 with no POSIX
utilities on PATH.
Showcases run too, on the runtimes that are not GJS:
gjsify showcase # list themgjsify showcase express-webserver # runs on node heregjsify showcase express-webserver --runtime bun # or denogjsify showcase picks the runtime itself — gjs when a gjs is installed,
otherwise the host runtime — so on Windows that middle line runs the showcase’s
--app node bundle without being told to. A showcase that declares no support
for the chosen runtime says so instead of crashing.
What a Windows checkout cannot do is anything that ends in a gjs process:
--app gjs bundles, test:gjs, and the showcases and examples built around
them. That is the same upstream blocker as above, not a separate gap. Concretely
it means a showcase’s build:node succeeds where its sibling build:gjs cannot,
so build the target you can actually run.
Two host settings matter, and both fail in ways that do not name themselves:
- Long paths. Set
HKLM\SYSTEM\CurrentControlSet\Control\FileSystem \LongPathsEnabledto1(needs elevation), and keep the checkout short —C:\src\…rather than a deep home directory. A monorepo with nestednode_modulesexceedsMAX_PATHquickly. - Line endings.
core.autocrlf=falseis still the setting to prefer, but a default Git for Windows clone is not dirty: measured atmain, acore.autocrlf=truecheckout of all 4781 tracked entries reports zero modified files, and no tracked blob contains a CR byte. (An earlier note here claimed 744 committed-CRLF files and a needed repo-wide renormalisation; it did not reproduce — seestatus/open-todos.md.) What the setting protects is the byte-verified artifacts, and.gitattributesalready pins those to LF with-textwhatever you configure, because otherwiseverify-committed-bundles.mjsreports them stale for files you never touched.
Optionally, enable Developer Mode so unprivileged file symlinks work. It is
not required: gjsify install links workspace packages with NTFS junctions,
which need no privilege. Three test rows do need real file symlinks and report
themselves as skipped with that reason when the capability is absent.
One caveat when testing, because it produces false greens: run commands the way
npm does, through cmd.exe. A git-bash shell puts C:\Program Files\Git\usr\bin
on PATH, so chmod, cp, rm and which resolve there and a script that
cannot work for a normal user appears to.
Why macOS has no Node-free toolchain yet
Section titled “Why macOS has no Node-free toolchain yet”gjsify build under GJS needs a bundler, and @gjsify/rolldown-native is the only
bundler engine that runs there.
The Rust-level blocker is gone: the wakeup channel that drives the plugin bridge used
to be three eventfd(2) descriptors — a Linux-only syscall the libc crate does not
expose on Apple targets, so the crate did not compile for macOS at all. It is now
three anonymous pipes, one portable implementation on every platform, and
cargo check --target aarch64-apple-darwin passes.
What is still missing is a native macOS build: nothing has linked the library,
generated its typelib, or loaded it under Homebrew’s gjs yet. Until that leg is
green, a macOS user needs Node.js for the build step and then runs the result on GJS.
Everything else in the toolchain (@gjsify/lightningcss-native,
@gjsify/oxfmt-native) already builds for macOS.
Native bridge matrix
Section titled “Native bridge matrix”Native bridges are the packages that ship a compiled artifact. Each declares the
<os>-<arch> targets it promises in package.json#gjsify.platforms, and CI is held
to that declaration in both directions — a promised target that nothing builds and a
built target that nothing promises are both hard failures.
A declaration is not enough on its own, so scripts/audit-runtimes.mjs --check also
holds the promise to a body: every declared target of a package that names a
committed prebuilds/ directory must have that directory, holding a shared library
in that OS’s format plus the GI typelib that names it. Those artifacts are then
verified as far as the checking host allows — see
What the checks actually prove below.
| package | tier | darwin-arm64 | darwin-x64 | linux-arm64 | linux-ppc64 | linux-riscv64 | linux-s390x | linux-x64 | win32-x64 |
|---|---|---|---|---|---|---|---|---|---|
@gjsify/http-soup-bridge | 1 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · |
@gjsify/http2-native | 1 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · |
@gjsify/lightningcss-native | 1 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · |
@gjsify/napi | 3 | ○ | · | · | · | · | · | ○ | · |
@gjsify/node-gi | 2 | ○ | ○ | ○ | · | · | · | ○ | ○ |
@gjsify/oxfmt-native | 1 | ✓ | ✓ | ✓ | · | · | · | ✓ | · |
@gjsify/rolldown-native | 1 | ✓ | ✓ | ✓ | · | · | · | ✓ | · |
@gjsify/sab-native | 1 | · | · | ✓ | ✓ | ✓ | ✓ | ✓ | · |
@gjsify/terminal-native | 1 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · |
@gjsify/tls-native | 1 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · |
@gjsify/webgl | 1 | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | · |
@gjsify/webrtc-native | 1 | · | · | ✓ | ✓ | ✓ | ✓ | ✓ | · |
✓ declared, a CI job targets it, artifact committed ·
○ declared, a CI job targets it, artifact NOT committed here ·
⚠ committed artifact, no CI job targets it · ! declared, no CI job targets it ·
? produced, undeclared · · unsupported
The marks are read out of the workflow YAML, so “a CI job targets it” is exactly
what they claim — not that a green run of that job exists. ○ in particular says
nothing about whether the job currently succeeds.
One row per bridge, and ✓ means the binary is committed in this repository —
in the bridge’s per-target package (@gjsify/<bridge>-<os>-<arch>, an
optionalDependencies entry a package manager installs or silently skips), not in
the bridge’s own tarball. Since that split the bridge itself carries no
prebuilds/ directory at all, so a cell that asked the bridge alone answered
“declared and built” and printed ✓ for two bridges that commit nothing anywhere;
the glyphs are now machine-checked against the directories on disk
(tests/e2e/ci-runner-arch).
Every column above is a real directory name. Targets are spelled the one way a running
process can compute about itself — ${process.platform}-${process.arch} — so
gjsify.platforms, the committed prebuilds/<target>/, the CI job that builds it and
the resolver that loads it all use the same string, with nothing to translate between them.
○ — declared and built, artifact not in this repository
Section titled “○ — declared and built, artifact not in this repository”○ is the one state that needs reading carefully: CI produces the artifact, but
this repository does not carry it, so a git clone has nothing to load and an
npm install gets whatever the last publish shipped. It is never a gap the audit
overlooked — every ○ target is held to a release.yml job that builds,
load-tests and uploads it, because a release cannot download another workflow’s
artifact and that tarball is the only route by which one reaches a consumer. The
two current causes:
@gjsify/napi, both targets —napi.ymlrebuilds and gates on the linux-x64 prebuild, and its macOS job builds, load-tests and uploads the darwin-arm64 one; no job commits either back, and each per-target package states exactly that inpackage.json#gjsify.platformsUncommitted, printed on every--checkrun. The linux-x64 directory used to be committed while its darwin sibling was exempt — one bridge running two policies, where every job that touched the path overwrote the checked-out bytes before reading them and the declaration checks stayed green across a week of source drift. Deleting it made freshness real by removal: the only linux-x64 artifact anyone can load is now one CI just built.@gjsify/node-gi, every declared target — it builds with node-gyp at install time, or installs a prebuild straight from a release artifact, so there is no committed directory anywhere and no exemption entry to key the cell on: the absence is the state.release.ymlcarries a prebuild leg per target, which is what the audit checks.
Check the matrix against reality at any time:
node scripts/audit-runtimes.mjs --platforms # human-readablenode scripts/audit-runtimes.mjs --platforms --markdown # this tablenode scripts/audit-runtimes.mjs --check # CI gate, every auditWhat the checks actually prove
Section titled “What the checks actually prove”The audit runs wherever CI runs it — today an ubuntu-latest x64 Node runner — and a
prebuild for another architecture cannot be loaded there. Rather than skip those, the
audit splits what it verifies and says which it did:
- Structurally, on every committed artifact regardless of target. The image’s own
machine must match the directory it sits in (an ELF/Mach-O/PE header read, no
readelf/otool); everylibgjsify*sibling it records must be staged beside it and reachable through$ORIGIN/@loader_path; and every library leaf the typelib records must be present, because that leaf is what GObject-Introspection hands to the loader the moment a consumer resolves a class. This is the half that caught both the missing macOS sibling cdylib and an emulated prebuild leg that compiled x86-64 and staged it intoprebuilds/linux-{ppc64,s390x,riscv64}/:uraimo/run-on-arch-actionignores itsarchinput whenever a custombase_imageis supplied, so every other check passed and the artifact even loaded on the runner. Only reading the image’s own machine field against its directory name catches that. - Functionally, only for the checking host’s own target. The library is
dlopened with every library-path environment variable stripped, which proves the self-relative sibling hop for real instead of inferring it from the headers. A bridge whose system dependencies the runner lacks (libsoup, GStreamer, libgda) is reported as not-load-tested, never as broken — that would be a fact about the runner, not the artifact.
So a ✓ means “declared, targeted by a CI job, and committed with a structurally
sound artifact”; it does not mean anyone has run that artifact on that architecture.
The per-run summary states both numbers separately.
What a missing bridge actually costs you
Section titled “What a missing bridge actually costs you”A native bridge is always optional at runtime — every one of them is loaded through
a guarded imports.gi probe with a predicate (hasNativeSab(), hasNativeTls(),
…), so a missing prebuild degrades rather than crashes. What you lose:
| bridge | without it |
|---|---|
@gjsify/http-soup-bridge | @gjsify/http’s server loses its GC-safe Soup message wrapper |
@gjsify/http2-native | no raw h2c; createServer() stays HTTP/1.1 |
@gjsify/tls-native | no OCSP parsing, no TLS session resumption or channel binding |
@gjsify/terminal-native | isatty/window size/raw mode fall back to env heuristics |
@gjsify/sab-native | no cross-process SharedBuffer; the rest of worker_threads is unaffected |
@gjsify/webgl, @gjsify/webrtc-native | WebGL / WebRTC unavailable |
@gjsify/sab-native is the one bridge that is Linux-only by design rather than
by missing CI: it is built on memfd_create, Linux futex syscalls and
SCM_RIGHTS. The reasoning, including why the obvious “portable” rewrite is a worse
contract, is written up in
ADR 0013.
What is verified, and where
Section titled “What is verified, and where”Following the project’s own rule — describe what is actually validated, by name, rather than making a runtime-class claim:
- Linux is the CI baseline. The full test suite (10,000+ cases across Node and GJS), every e2e suite and every integration suite run on Fedora.
- macOS is covered by
@gjsify/node-gi’s own CI (build, conformance, a real GTK/Adwaita window, the full Adwaita storybook), by@gjsify/napi’s build and gates, and by the native-bridge prebuild job. Two jobs run the@gjsify/*suites themselves:main.yml’smacosjob executes a named subset of--app gjsbundles on macOS/arm64 under Homebrewgjs1.88 (see below), andmacos-suites.ymlruns the Node-pillar suites on bothtest:nodeandtest:gjs, on darwin-arm64 and darwin-x64, onmainand nightly rather than per PR (ADR 0018 § 5). macOS is the only one of the three operating systems where both legs can run at all, and that is the point of running them:test:nodesays the spec is right,test:gjssays our port is. - Windows additionally runs the Node-pillar
@gjsify/*suites —utils,path,os,process,util,fs,child_process,net,worker_threads,node-globalsand@gjsify/cli’s own — inwindows-suites.yml, onmainand nightly rather than per PR (ADR 0018 § 5). It runs under cmd.exe with the git-bash utilities stripped fromPATH, because Git for Windows supplies a realrm/cp/shthat npm’s%COMSPEC%scripts do not have — testing from git-bash reports false greens. Note the runner is ELEVATED, so@gjsify/fs’s symlink specs execute there; on an unprivileged host they are tolerated instead, and the leg prints which of the two it measured. - Windows is also covered by
@gjsify/node-gi’s CI, including a real GTK window and the storybook, using a bundled GTK runtime rather than a system install.
The @gjsify/* GJS suites on macOS
Section titled “The @gjsify/* GJS suites on macOS”main.yml’s macos job runs these bundles under gjs on macos-latest
(arm64). It is a curated subset, not the whole suite, and it is deliberately
split into packages that dispatch on the host OS and pure-TS controls that must
behave identically:
| package | why it is in the set |
|---|---|
@gjsify/v8 | src/heap/darwin.ts reads process memory with ps -o rss=,vsz= (macOS has no procfs); the suite asserts used_heap_size > 0, so the reader is really exercised |
@gjsify/child_process | src/platform/darwin.ts — the BSD signal table (SIGUSR1 is 30 on Darwin, 10 on Linux), detached without setsid(1), and the in-process communicate.ts timeout that replaced GNU timeout(1) |
@gjsify/path | POSIX/Win32 dispatch plus the largest pure-logic suite in the set |
@gjsify/querystring, @gjsify/string_decoder | pure-logic controls |
@gjsify/buffer | control for macOS SpiderMonkey itself (Blob/atob/btoa) |
The bundles are produced on the Fedora leg and executed on macOS. That split is
forced, not incidental: @gjsify/rolldown-native has no Apple target (see
above), so a build on macOS would have to run under Node — which means a full
gjsify install on a runner billed at 10×. A --app gjs bundle is a single
self-contained file whose only unbundled imports are gi://GLib, gi://Gio and
gi://GioUnix, so building it on Linux and running it on macOS is sound. What
this verifies is runtime behaviour on macOS; it does not verify the gjsify
build toolchain there.
The job runs on push-to-main and on the nightly sweep. On a pull request it is
skipped unless the PR carries the ci:macos label.
@gjsify/os is in the gated set now. It used to run as a non-gating probe;
the reason given here — that getOs() shelled out to uname -o, a GNU
extension Darwin’s uname rejects — had already been fixed in the code and the
note outlived it. What the probe was actually catching was os.cpus() returning
a times object with no members. It now reports the documented all-zero
contract, and the one reading macOS genuinely cannot produce is carried by an
it.failing(…, { when }) assertion rather than by keeping a whole package
ungated.
What the darwin legs found
Section titled “What the darwin legs found”The OS axis exists because a declaration nothing exercises is a guess. Measured
on the darwin-x64 VM and on the arm64 runner, against a main every existing
check called green:
| package | symptom | mechanism |
|---|---|---|
@gjsify/child_process | the whole suite: spawnSync returned status: null, pid: 0, empty stdout | the specs spawn process.execPath, and @gjsify/process reported the SCRIPT rather than the interpreter — so the file being spawned was the test bundle |
@gjsify/process | pid, ppid and memoryUsage().rss all 0 | three inline /proc/self/* reads with no fallback; 0 is a valid pid, so nothing could detect the wrong answer |
@gjsify/os | cpu.times.user undefined where Node guarantees a number | a getter that warned once per core and returned {} |
@gjsify/net | isIP('01.02.03.04') answered 4; Node answers 0 | delegating to Gio.InetAddress, i.e. to the host’s inet_pton(3) — BSD accepts leading zeros, glibc rejects them |
Three of the four cannot fail on Linux at all, and the fourth cannot fail under native Node. That is the shape of hole a single-OS pipeline leaves.
One macOS fact is worth stating separately because it invalidates the obvious
fix for a dyld problem: DYLD_* environment variables do not survive a shell
boundary. SIP strips them when a protected binary is exec’d, and /bin/sh
and /bin/bash are protected — so DYLD_LIBRARY_PATH=… ; some-script.sh loses
the value before the script’s first command. Measured:
DYLD_LIBRARY_PATH=x /bin/sh -c 'echo $DYLD_LIBRARY_PATH' # prints nothingDYLD_LIBRARY_PATH=x node -p 'process.env.DYLD_LIBRARY_PATH' # prints xThis is why @gjsify/cli repairs the loader path on the CHILD’s launch env
(buildNativeEnv() → spawn('gjs', …, { env })), where there is no shell in
between, rather than exporting it anywhere — and why a workflow-level export is
a no-op. macos-suites.yml prints the measurement on every run so the next
person to reach for the variable is told why it cannot work.
The @gjsify/* suites on Bun and Deno
Section titled “The @gjsify/* suites on Bun and Deno”Bun and Deno share the node runtime slot with Node through the Node-API common
ABI. main.yml’s cross-runtime job tests that rather than assuming it: it
builds one engine-agnostic --app node bundle per package and runs it on all
three runtimes.
Covered today: @gjsify/cli, @gjsify/unit, @gjsify/adwaita-core,
@gjsify/storybook-core, @gjsify/domparser, @gjsify/webstorage,
@gjsify/semver, @gjsify/workspace, @gjsify/npm-registry, @gjsify/tar.
The selection rule matters. A spec that imports node:path gets the host
runtime’s builtin, because the --app node bundle externalises it — such a leg
would test Bun, not the polyfill. Every package above either imports its own
package by name, or imports a bare Web specifier that ALIASES_WEB_FOR_NODE
routes to the polyfill, or is infra code that is nobody’s builtin. Running the
node:-backed polyfill suites against their own implementation on Bun/Deno needs
the --alias node:<name>=@gjsify/<name> retarget that
scripts/node-gi-consumer-harness.mjs performs; that is not wired into CI yet.