Skip to content

How It Works

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.

TypeScript
// You write:
import { readFileSync } from 'node:fs'
import { createServer } from 'node:http'
const res = await fetch('https://example.com/')
const ws = new WebSocket('wss://echo.example.com')
TypeScript
// The plugin resolves that to:
import { readFileSync } from '@gjsify/fs' // → Gio.File
import { createServer } from '@gjsify/http' // → Soup.Server
// fetch → Soup.Session (via @gjsify/fetch)
// WebSocket → Soup.WebsocketConnection (via @gjsify/websocket)

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.

TargetWhat it rewritesWhy
--app gjsNode.js and Web API imports, to their @gjsify/* equivalentsGJS has no node: builtins and no DOM
--app nodegi:// imports, onto the node-gi bridgeNode.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:

Build the bundle
Terminal
gjsify build src/index.ts --app gjs --outfile dist/index.js
# --globals auto is the default, so there is no flag to pass
Terminal
npx @gjsify/cli@latest build src/index.ts --app gjs --outfile dist/index.js
# --globals auto is the default, so there is no flag to pass
Terminal
bunx @gjsify/cli@latest build src/index.ts --app gjs --outfile dist/index.js
# --globals auto is the default, so there is no flag to pass
Terminal
deno run -A --reload --min-dep-age 0 npm:@gjsify/cli@latest build src/index.ts --app gjs --outfile dist/index.js
# --globals auto is the default, so there is no flag to pass

Use --verbose to watch it converge, and --globals auto,<name> for what the analyser cannot see:

Terminal
gjsify build src/index.ts --app gjs -o dist/index.js --verbose
# [gjsify] --globals auto: converged after 2 iteration(s), 11 global(s)
gjsify build src/index.ts --app gjs -o dist/index.js --globals auto,matchMedia
What auto detection does

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:

  1. 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.
  2. 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.
  3. 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.

ModeBehaviour
--globals autoThe 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 noneNo 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:

Code
[gjsify] --globals auto: skipping register import '@gjsify/webgl/register'
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.

Code
node_modules/@gjsify/webgl-linux-x64/prebuilds/linux-x64/
Gwebgl-0.1.gir
Gwebgl-0.1.typelib
libgwebgl.so

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.

Run the bundle
Terminal
gjsify run dist/index.js
Terminal
npx @gjsify/cli@latest run dist/index.js
Terminal
bunx @gjsify/cli@latest run dist/index.js
Terminal
deno run -A --reload --min-dep-age 0 npm:@gjsify/cli@latest run dist/index.js
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:

Export the environment
Terminal
eval $(gjsify info --export)
gjs -m dist/index.js
Terminal
eval $(npx @gjsify/cli@latest info --export)
gjs -m dist/index.js
Terminal
eval $(bunx @gjsify/cli@latest info --export)
gjs -m dist/index.js
Terminal
eval $(deno run -A --reload --min-dep-age 0 npm:@gjsify/cli@latest info --export)
gjs -m dist/index.js

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
PluginWhat it does
@gjsify/vite-plugin-blueprintCompiles .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-gettextRuns the xgettext / msgfmt / po2json pipeline, the same plugin in both environments
@gjsify/rolldown-plugin-solidCompiles SolidJS JSX in universal generate mode, so the output calls a GTK renderer instead of the DOM
@gjsify/rolldown-plugin-vueCompiles Vue 3 single-file components for the @vue/runtime-core GTK renderer

The presets that mirror gjsify build --app browser and --app nativescript ship as @gjsify/vite-plugin-gjsify. The Vite Plugin guide has the config.

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.

Adwaita on the web: light DOM and design tokens

Section titled “Adwaita on the web: light DOM and design tokens”

@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.

One behaviour layer behind the Adwaita ports

Section titled “One behaviour layer behind the Adwaita ports”

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.

SubpathWhat it installs
@gjsify/canvas2d/registerglobalThis.ImageData and globalThis.Path2D, plus the DOM pillar’s @gjsify/dom-elements/register/canvas
@gjsify/webgl/registerglobalThis.WebGLRenderingContext and globalThis.WebGL2RenderingContext. The 'webgl' / 'webgl2' factories are not here: they come from the canvas subclass WebGLBridge creates.
@gjsify/iframe/registerglobalThis.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:

Code
schtasks /create /tn gjsifyTest /tr "%USERPROFILE%\test\run.cmd" `
/sc once /st 23:59 /ru %USERNAME% /it /f
schtasks /run /tn gjsifyTest

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.