gjsify build and gjsify run do everything described here on their own. Read it when a build
behaved in a way the task guides don’t explain; each section stands on its own. Which target runs
where is in Runtimes, every flag is in the
CLI Reference.
One source file has to build for runtimes that do not ship the imports it uses, so the plugin
rewrites them: --app gjs rewrites the Node.js and Web API imports to their @gjsify/* equivalents,
--app node rewrites gi:// imports onto the node-gi bridge.
The bare specifier is canonical: no gjsify-specific import appears in your source, so one file
type-checks against @types/node and builds for either target.
The alias table, and what each target leaves alone
The alias table lives in @gjsify/resolve-npm, and its coverage is in the
Packages Overview.
Target
What it rewrites
Why
--app gjs
Node.js and Web API imports, to their @gjsify/* equivalents
Node.js, Bun and Deno have no GObject Introspection
--app gjs keeps gi://*, cairo, system and gettext untouched, because the GJS runtime
resolves those itself. --app node keeps the node: imports untouched, because the host
runtime already has them, and one such bundle runs on Node, Bun and Deno alike.
The @gjsify/* packages still have to be resolvable from your project. gjsify create puts the
ones a template needs into package.json; reach for a new API later and you add its package
like any other dependency.
The GJS target compiles down to firefox140, which is SpiderMonkey 140, the engine in GJS 1.86.
Plenty of npm code reaches for globals with no import at all: process.env.FOO,
Buffer.from(data), await fetch(...). On GJS those do not exist until something registers them
on globalThis; --globals auto works out which ones by analysing the bundled output:
Every @gjsify/* package that provides a Node or Web global exposes one or more /register subpaths,
for instance @gjsify/fetch/register/fetch and @gjsify/node-globals/register/process. Importing a
subpath for its side effect runs the registration; importing only named exports from the package root
stays tree-shakeable.
It is an iterative build:
Pass 1 bundles your code in memory, unminified, with no globals injected.
acorn parses the result and looks for two patterns: free
identifiers (Buffer, fetch, process) that nothing in the bundle declares, and host-object
member expressions (globalThis.Buffer, global.Buffer, window.Buffer, self.Buffer),
which is how many npm packages reach a global. Every match against the known-identifier table
counts as a discovered global.
Pass 2 rebuilds with those globals injected as small register stubs, seeded with the
precomputed set of globals the registers themselves reference. Injected code can pull in more
code, so the loop repeats until the detected set stops growing. Two passes is the normal case,
five is the hard cap.
The final build uses the converged set and writes to disk.
Detection runs on the bundled, tree-shaken output rather than on your source tree, and that
one choice is what makes it dependable. An identifier that survives dead-code elimination is
genuinely reachable. So an isomorphic dependency that guards document behind
typeof document !== 'undefined' produces no false positive, and a file the bundler loaded and
then shook away contributes nothing.
The analyser follows identifiers, not values. If a package stores globalThis in a field and then
calls a method on that field, neither the bare name nor globalThis.<name> ever appears in the
bundle, so the detector misses it. Extras are seeded into the very first pass, so code reachable
only through them becomes visible too. --globals auto,dom takes a whole group.
Mode
Behaviour
--globals auto
The default: fully automatic detection
--globals auto,<extras>
Auto plus an explicit safety net
--globals fetch,Buffer,…
A fully explicit list, or the groups node, web, dom. No auto detection.
--globals none
No globals injection at all
--exclude-globals <list>
Drop identifiers auto found but you do not want
Every @gjsify/* package that provides a Node or Web global exposes one or more /register
subpaths, for instance @gjsify/fetch/register/fetch and
@gjsify/node-globals/register/process. Importing a subpath for its side effect runs the
registration; importing only named exports from the package root stays tree-shakeable. Registers are
granular: each polyfill package splits its register code per feature, so detecting Buffer injects
@gjsify/node-globals/register/buffer and nothing else.
If a detected global maps to a package your project doesn’t have, the build says so and skips it
rather than failing:
package '@gjsify/webgl' is not installed in this project.
--app defaults to the target of the runtime the CLI is on: gjs under gjs, node under node,
bun and deno. Set gjsify.app in package.json to fix the target for a project and the flag is
optional again. --app browser uses the host’s own globals and injects nothing.
The full identifier table, and the register subpath each name maps to, lives in the
CLI Reference.
Fixing `ReferenceError: X is not defined`
If your GJS bundle crashes with ReferenceError: X is not defined, the global X was not
detected. Look it up in the identifier table in the
CLI Reference and add it as an extra:
--globals auto,X, or take its whole group with --globals auto,dom. If X is not in the table,
gjsify does not implement it yet: check the Packages Overview or
open an issue.
Some gjsify packages need native code. Those binaries are not in the main package: such a package
declares its targets in "gjsify": { "platforms": [...] } and lists a companion package per target
as an optionalDependency.
The companion carries the files under prebuilds/<os>-<arch>/, so an install only fetches the
binary for the machine you are on.
gjsify run sweeps your node_modules for those packages, prepends what it finds to
GI_TYPELIB_PATH and the loader’s own search variable, then launches the bundle: gjs -m <bundle>
for an --app gjs one, the runtime’s own binary for an --app node one.
Target spellings, the launcher, and calling `gjs` yourself
@gjsify/webgl is the worked example: a Vala-built shared library plus a GIR typelib that routes
WebGL calls through libepoxy.
The library-search variable is LD_LIBRARY_PATH on Linux, DYLD_LIBRARY_PATH on macOS (dyld
ignores LD_LIBRARY_PATH entirely) and PATH on Windows. Select the launcher with --runtime node,
bun or deno for an --app node bundle.
There is one spelling for the prebuilds directory, the same one a running process can compute
about itself: linux-x64, linux-arm64, darwin-arm64, win32-x64, plus a -musl suffix on
Linux hosts whose C library needs it. A package that ships nothing for your platform is skipped in
silence, because every native bridge is optional at runtime.
With no --runtime, gjsify run uses gjs when it is the host or when the file is recognisably
a GJS bundle, and otherwise the runtime it is already on. The sweep is the same either way,
because a Vala bridge ships its typelib under prebuilds/ no matter which runtime loads it.
If you want to call gjs directly, from a systemd unit or inside a packaged Flatpak, ask the CLI
to print the environment:
gjsify info without --export prints the same information for humans. macOS adds a constraint:
SIP strips every DYLD_* variable at the first /bin/sh boundary, so the process that sets
DYLD_LIBRARY_PATH has to be the one that launches the bundle. ADR
0024 § 3
has the measurement.
Which prebuilt artifacts actually exist, per package and per platform, is tracked in
Platform Support, generated from the same rule the CI audit runs.
On --app gjs, a Node-style server runs on Gio async I/O, which needs a running GLib main loop.
http.Server.listen(), net.Server.listen() and dgram.Socket.bind() start one for you, so
application code calls nothing; GTK applications carry on using Gtk.Application.runAsync(). On
--app node there is nothing to arrange, and node-gi arranges a
gi:// bundle that needs both loops per runtime.
Gjsify’s build plugins are Rollup-shaped, so the same plugin objects load under Rolldown (which
gjsify build drives) and under Vite — a dev server with hot module reload and a production
gjsify build --app browser then produce the same output instead of two pipelines that drift apart.
The four plugins worth using on their own
Plugin
What it does
@gjsify/vite-plugin-blueprint
Compiles .blp to an XML string with the in-repo @gjsify/blueprint parser — no blueprint-compiler, nothing spawned — so import T from './window.blp' works in Vite dev and in a production build
@gjsify/vite-plugin-gettext
Runs the xgettext / msgfmt / po2json pipeline, the same plugin in both environments
A GJS bundle resolves its bundled dependencies’ data files from the bundle’s real on-disk location
at runtime, so a dependency that loads its own package.json, translation catalogue or templates
through import.meta.url keeps working once the bundle is installed globally, at a different
node_modules depth than it was built at. Yarn PnP workspaces are covered too. Neither needs
configuration.
@gjsify/adwaita-web renders each widget as a light-DOM custom element styled
by one global stylesheet, not hiding its markup inside a shadow root. Every rule stays reachable, so
a higher-specificity selector of yours wins the way it normally would.
The cost of the light DOM, and why the tokens are the API
A host page’s own CSS reaches into the widgets, so each widget re-establishes the Adwaita
typographic and box-model context at its own boundary instead of inheriting whatever the
surrounding page set. The components render server-side with nothing extra, since there is no
shadow root to serialise past.
That is why the design tokens are the supported way to re-theme. Custom
properties cross a shadow boundary and ordinary CSS rules do not, so a theme written against the
tokens keeps working even if a component later moves behind one. See ADR
0010 for
the full decision.
The web and NativeScript Adwaita ports do not each implement widget behaviour: both compose
@gjsify/adwaita-core, so a behaviour fix lands once and both pick it up.
What `@gjsify/adwaita-core` is
A pure-TypeScript package with no platform imports
(ADR 0004)
that owns the parts which are logic rather than rendering, which is why each port keeps only its own
half. It lives in
packages/web/adwaita-core.
Importing a bridge package has no side effects:
import { Canvas2DBridge } from '@gjsify/canvas2d' gives you the named exports and writes nothing
to globalThis. The browser globals a package owns live behind a /register subpath instead, and
you normally import none: gjsify build’s default --globals auto reads the identifiers in
the bundled output and injects the matching one.
The register subpaths, and the imperative alternative
The bridges do not depend on the globals they publish, so they also work under --globals none.
Subpath
What it installs
@gjsify/canvas2d/register
globalThis.ImageData and globalThis.Path2D, plus the DOM pillar’s @gjsify/dom-elements/register/canvas
@gjsify/webgl/register
globalThis.WebGLRenderingContext and globalThis.WebGL2RenderingContext. The 'webgl' / 'webgl2' factories are not here: they come from the canvas subclass WebGLBridge creates.
@gjsify/iframe/register
globalThis.HTMLIFrameElement and the document.createElement('iframe') factory
bridge.installGlobals() is the imperative alternative, and each bridge installs only what it owns:
Canvas2DBridge publishes requestAnimationFrame and performance, WebGLBridge adds
cancelAnimationFrame, devicePixelRatio and the two context constructors, VideoBridge publishes
HTMLVideoElement, and IFrameBridge publishes HTMLIFrameElement plus the element factory. The
/register subpaths leave an existing global alone, while an explicit installGlobals() means “make
mine the one everybody gets” and overwrites. That is why two bridges calling it in the same process
leaves the second one in charge. ADR
0012
records the split.
document and window come from neither: they are the shared polyfill in
@gjsify/dom-elements/register, which --globals auto injects on the same rules. The one exception
is @gjsify/video, whose VideoBridge also keeps a private document / window pair on
bridge.environment for code that wants a DOM tree scoped to one widget.
gjsify install builds node_modules itself, without shelling out to npm. The resolver follows npm
v3 and later semantics, so the layout is the one every other tool expects: a placement that
satisfies a range is reused from the ancestor node_modules chain, and a conflict nests the
dependency under the requester.
The lockfile, pinning, and what a re-resolve touches
The result is a lockfile whose packages map is keyed by install path, at lockfileVersion 4.
Two escape hatches let a workspace root pin a transitive dependency the deduplicated tree would
otherwise resolve differently: npm’s top-level overrides and yarn’s resolutions. Both are read
from package.json, and both apply to every edge regardless of who requested it.
A resolve that has to run preserves the versions already pinned in the lockfile and only resolves
what is genuinely new or changed, exactly as npm, yarn and pnpm do. --refresh-lockfile is the
opt-in that re-resolves everything to the newest in-range version.
Packing the same build twice with gjsify ship gives
byte-identical files. Timestamps come from the built bundle’s mtime, or from SOURCE_DATE_EPOCH when
that is set — which is what lets two checkouts of the same source produce identical bytes.
The guarantee holds per JavaScript runtime
Pick one runtime for your release builds and the artifacts stay comparable.
The <os>-<arch> marks in the Platform Support table are generated from
the same rule the CI gate runs (scripts/audit-runtimes.mjs --check), so the page and the audit
cannot disagree.
What `✓` and `○` mean, and the per-runtime caveat
The marks are read out of the workflow YAML, so “a CI job targets it” claims exactly that, never
that a green run of that job exists.
✓ 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. It does not mean anyone has run that artifact on
that architecture. Read it as “declared, targeted by a CI job, and committed with a structurally sound
artifact”.
○ is the state to read 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. Two packages are in that state, @gjsify/napi on both its
targets and @gjsify/node-gi on every target it declares. ○ also says nothing about whether the
job currently succeeds.
Every column name 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.
How the audit arrives at those marks, what it checks structurally on every artifact and what it can
only check functionally on its own architecture, is in
Architecture.
The same programs run on gjs, node, bun and deno, and their output has to match byte for
byte. That four-runtime comparison is a Fedora one: it needs all four hosts on one machine, and only
Linux has them. macOS runs gjs and Node, Windows runs Node alone — there is no GJS host there.
Windows traps that look like defects
Three host traps on Windows fail in ways that name the wrong culprit. The first two each cost a
verification run an entire discarded pass.
Win32-OpenSSH puts its session in session 0, which has no desktop. Every GTK showcase driven over
SSH died with an 0xC0000005 access violation inside gtk-4-1.dll, a crash with a plausible stack and
no hint of the real cause. It reads like a gjsify defect and is not one. Re-run as an interactive
scheduled task in the logged-on session, the same commands render:
Write output under %USERPROFILE%: an unelevated task may not write to C:\.
Check the CLI version before believing a Windows result. A run reporting every WebGL showcase
failing under deno with Failed to require Gwebgl 0.1: Typelib file for namespace 'Gwebgl' (any version) not found was measured against that box’s npm-global gjsify 0.33.0. Re-measured on 0.37.0
in an interactive session, all four WebGL showcases reach the GL bridge under both deno and bun, and
the error does not occur at all. gjsify showcase prints the version it is running as, so read that
line first.
Run commands the way npm does, through cmd.exe. This one produces false greens. 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 work.
The conformance harness, the Fedora baseline and those jobs are in
Architecture.