Packaging and distribution
Four ways to ship a react-x11 app, cheapest first. Every recipe here was run; where something does not work, the reason is measured rather than guessed.
| tier | works | cost |
|---|---|---|
1. plain npm install | yes | a node_modules on the target |
2. a single .mjs | yes, with one esbuild flag | one file, ~7 MB |
| 3. Node single executable (SEA) | yes, as CommonJS | one file, ~142 MB |
4. AppImage / .deb | yes, wrapping tier 1 or 2 | packaging metadata |
Tier 1 — npm install
The default, and fine for a server-side app, a CI tool, or anything already
deployed with a package manager. Nothing to say beyond: the package is ESM
("type": "module"), it needs Node ≥ 22, and it has no native
dependencies — ntk, node-x11 and yoga-layout are pure JavaScript and WASM,
so there is no node-gyp, no prebuild matrix, and npm install on a fresh
box just works.
Tier 2 — one .mjs file
esbuild app.jsx --bundle --platform=node --format=esm --outfile=app.mjs \
--banner:js="import{createRequire as __cjsRequire}from'node:module';const require=__cjsRequire(import.meta.url);"
node app.mjs
Three things about that command are load-bearing.
--format=esm is the easy path, not the only one. It tolerates top-level
await anywhere in the graph; cjs does not, and refuses to build with
Top-level await is currently not supported with the "cjs" output format.
Since ntk 5 nothing in this stack forces the issue — see
tier 3, which needs cjs — so the choice
is only about what your own code does at module scope.
The --banner:js is not optional. node-x11 is CommonJS and uses dynamic
require, which esbuild cannot resolve statically. Without the banner the
bundle builds cleanly and then dies at startup:
Error: Dynamic require of "events" is not supported
The banner defines the require that shim calls. This is an upstream gap —
node-x11 wants an ESM entry point, or node:-prefixed requires — and until
it closes, the banner is the fix.
The banner's import must be aliased. The obvious form, import {createRequire} from 'node:module', collides with react-x11's own import of
the same name and the bundle fails to parse with Identifier 'createRequire' has already been declared. Hence as __cjsRequire above. That one costs an
afternoon if you meet it without warning.
Two things that are not problems, and are worth knowing because they usually are:
- yoga-layout is bundler-safe. Its WASM is base64-inlined into the
JavaScript, so nothing is emitted beside the bundle and nothing needs a
loader rule or a copy step. Verified: no
.wasmfile appears, and the bundle renders. keysymis gone. It used to read a JSON file off disk at module load and broke every bundle; ntk 4.3.0 no longer depends on it. If you are reading an older account of this, that half is fixed.
Verified end to end: bundling an app that imports react-x11, react and
node-x11's in-process X server produces a 7 MB app.mjs that mounts a tree,
paints, and reads its own pixels back.
If you use react-x11's DevTools or
click-to-component integrations, note that they are
dynamically imported behind environment variables. A bundler will pull in
ws and node:child_process for them; if you do not want that, stub them
out with a resolver plugin the way
website/scripts/build-demo-bundles.mjs does.
Tier 3 — Node single executable
esbuild app.jsx --bundle --platform=node --format=cjs --outfile=app.cjs
node --build-sea=sea.json # { "main": "app.cjs", "output": "myapp" }
./myapp
{ "main": "app.cjs", "output": "myapp", "disableExperimentalSEAWarning": true }
This did not work until recently, and the reason is worth keeping: Node's SEA
evaluates the embedded main as CommonJS — there is no package.json
inside the blob for it to consult, and the .mjs name has no effect on an
embedded script — while the stack forced ESM, because esbuild will not emit
CommonJS for a graph containing top-level await. The await was not ours:
yoga-layout's default entry is const Yoga = wrapAssembly(await loadYoga()), ntk imported it for HtmlView, and every app inherited it.
ntk 5 loads the layout engine through yoga-layout/load instead — enums
synchronously, WebAssembly during createClient() — and react-x11 moved its
own three await import()s for DevTools, click-to-component and tracing out
of module scope. Nothing in the graph has a top-level await now.
Verified: a bundle of react-x11, react, ntk and node-x11's in-process X server mounts a 40-row tree and paints it — 157 requests in 7 socket writes — from a single 142 MB file, most of which is node itself.
What the CommonJS format costs you, in your own code:
- No top-level
await. Put startup in anasync function main();const root = await createRoot()at the top level of your entry is enough to fail the build. esbuild names the file and line, so this is a five-second fix rather than a mystery. import.meta.urlisundefined. Anything resolving paths through it needsprocess.execPathor a literal. react-x11 already guards its own use of it (the version string DevTools shows falls back rather than throwing); check your app for the same pattern.- Runtime module loading is out. Inside a SEA,
require()andimport()resolve built-in modules only — adata:orfile:URL import fails withERR_UNKNOWN_BUILTIN_MODULE. A bundle has nothing left to resolve, so this only bites if you meant to load something later. It also closes the obvious workaround for the old ESM problem: you cannot carry an ESM bundle as a SEA asset and import it. - Assets are not files.
sea.getAsset()reads what the config'sassetsmap embedded; fonts are the usual case, andStaticFontSourcetakes bytes directly (ntk's fonts guide).
On macOS the binary must be re-signed before it runs
(codesign --remove-signature myapp && codesign --sign - myapp); on Linux
nothing extra. Measured on Node 26.
Tier 4 — AppImage, .deb, .rpm
These wrap tier 1 or tier 2; the packaging is the desktop-integration metadata rather than anything about JavaScript.
- A
.desktopfile — without one, the app has no launcher entry, no icon in the dock, and no association with its own windows. ItsStartupWMClassmust match thewmClassprop on your<window>(elements.md), or the desktop groups your window under the wrong icon. - Icons at 48, 128 and 256 px under
usr/share/icons/hicolor/<size>/apps/. Set_NET_WM_ICONtoo, which the window manager reads for the titlebar and alt-tab; the WM example already reads both sources (AGENTS.md, "Writing a window manager"). - Node itself, if you are not depending on a system one. That is most of the size.
Nothing here is react-x11-specific — any Node desktop app packages the same way — which is why this section is short rather than absent.
Checklist
- Pick the format deliberately:
--format=esm(tier 2, needs the banner) or--format=cjs(tier 3, needs no top-level await in your own code). - With
esm, alias the banner'screateRequire. - Do not ship
.Xauthority, and do not bakeDISPLAYinto an image — security.md. - Set
wmClassand match it inStartupWMClass. - Test on a display you did not develop on. Fonts are the usual surprise:
family resolution goes through
fc-match, sosans-serifis a different face on the target (remote.md, issue #86).
Upstream
One thing would delete the rest of the friction here:
- node-x11: ESM sources (node-x11#246). That removes tier 2's
banner, which is now the only real trap on the page. Note the tempting
cheaper version does not work:
node:-prefixed requires change nothing, because esbuild wraps a CommonJS module either way and itsrequireshim throws in ESM output regardless of the specifier — measured, not assumed.
ESM support for a SEA's embedded main would be welcome in Node, but it is no longer load-bearing: tier 3 works as CommonJS.