Agent-readable docs index: /llms.txt. Full docs in one file: /llms-full.txt. Download /docs.zip to grep all markdown files locally.

Run GPUIX on hermes-node for a smaller binary

hermes-node is a Node-compatible runtime on Hermes. GPUIX already talks to native code through Node-API, so the same @gpuix/native .node loads. The JS side must be CommonJS. Hermes has no ESM loader.
The point is size. A bun build --compile chat binary on this machine is 83 MB. A Hermes-linked counter is 12 MB plus the 22 MB native sidecar. 33 MB total.
bun build --format=cjs app.tsx ──────────────────────────────────────────► app.cjs React + GPUIX │ │ process.dlopen ▼ @gpuix/native ► GPUI ► GPU
The published macOS v0.0.2 tarball cannot load any .node addon. Rebuild hermes-node without -dead_strip first. Linux releases keep napi_* via -rdynamic. Tracked as tmikov/hermes-node#11.

Size

Measured on macOS arm64, Release, GPUIX counter:
ArtifactSize
hermes-node runtime11 MB
app.cjs (React + GPUIX, production)531 KB
app.bundle (Hermes bytecode)266 KB
gpuix-hermes executable12 MB
gpuix-native.darwin-arm64.node22 MB
exe + native, the ship set33 MB
bun build --compile chat, for scale83 MB
Almost all of the 33 MB is GPUI inside the .node. Hermes itself is the 12 MB exe. --build-exe cannot put the .node inside the Mach-O. dlopen needs a path.

Shrink the .node

The published addon is 22 MB on macOS arm64. napi build --platform --release already uses [profile.release] lto = true. It does not pass --strip. test-support is on by default so TestGpuixRenderer ships.
Measured on this machine, same Release binary:
StepSize
stock gpuix-native.darwin-arm64.node22 MB
strip -x (local symbols only)17 MB
strip with no -xfails. NAPI / AppKit imports stay
Rebuild without the test renderer:
cd packages/native bun run build:release -- --strip
That is napi build --platform --release --no-default-features --strip. --strip is the napi-rs flag that aims for minimum size. On macOS, strip -x is the safe extra step after a normal build. Do not run bare strip. It errors on symbols the dynamic linker still needs.
What will not drop tens of megabytes:
  • Dropping test-support. The test renderer is small next to GPUI.
  • More LTO. It is already on.
  • Hermes. The 22 MB is the addon, not the JS runtime.
The remaining 17 MB is GPUI: 11 MB __text, 1.5 MB unwind, 3 MB __const, plus Metal / font-kit / syntect. That is the floor until GPUI itself gets smaller.
Ship the published 22 MB unless you rebuild native yourself. --strip is a local opt-in. CI currently ships test-support on macOS and Windows.

1. Write a normal GPUIX app

Same React entry as Bun. End the file with render().
import { useState } from 'react' import { render } from '@gpuix/react' function App() { const [count, setCount] = useState(0) return ( <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', width: '100%', height: '100%', backgroundColor: '#11111b', }} > <text style={{ fontSize: 28, color: '#cdd6f4', fontWeight: 700 }} onClick={() => setCount((c) => c + 1)} > {String(count)} </text> </div> ) } render(<App />, { title: 'GPUIX Hermes', width: 480, height: 320 })
Install from a folder that can resolve workspace packages, or from a real app:
bun add @gpuix/react react

2. Rebuild hermes-node on macOS

hermes-node documents N-API addons. process.dlopen is wired. That is true on Linux. The macOS v0.0.2 binary exports zero _napi_* symbols.
tools/hermes-node/CMakeLists.txt also passes -Wl,-dead_strip on Apple. On ld64, -export_dynamic is not a GC root, so the NAPI C ABI is dropped. See tmikov/hermes-node#11.
git clone --recurse-submodules https://github.com/tmikov/hermes-node.git cd hermes-node
Delete the Apple target_link_options(... -Wl,-dead_strip) block in tools/hermes-node/CMakeLists.txt. Keep --gc-sections on ELF.
cmake -S . -B cmake-build-release -G Ninja \ -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_C_COMPILER=clang \ -DCMAKE_CXX_COMPILER=clang++ \ -DHERMES_ENABLE_TEST_SUITE=OFF ninja -C cmake-build-release hermes-node ninja -C cmake-build-release hermes-node-kit
Check the C ABI:
nm -gU cmake-build-release/bin/hermes-node | grep ' _napi_' | wc -l # expect 145, not 0
Put that hermes-node on your PATH, or use the absolute path below.
Hardened Runtime on the official binary also rejects an ad-hoc .node (different Team IDs). A local rebuild is ad-hoc without runtime. Sign both files with the same Team ID if you ship.

3. Bundle to CommonJS with bun build

Hermes parses import only in module mode. hermes-node never turns that on. import() is a syntax error: Invalid expression encountered.
bun build emits CJS. It still leaves a few runtime import(specifier) calls (the automation client, safe-mdx ESM helpers). Hermes cannot parse those. Rewrite leftover import( to require( with perl. That is enough for the desktop app path. Do not use esbuild.
Run this from a package that depends on @gpuix/react, for example examples/:
bun build app.tsx \ --target=node \ --format=cjs \ --outfile=app.cjs \ --external=@gpuix/native \ --production \ --banner='if(typeof queueMicrotask!=="function"){globalThis.queueMicrotask=function(fn){process.nextTick(fn)};}if(typeof performance==="undefined"){globalThis.performance={now:function(){var t=process.hrtime();return t[0]*1e3+t[1]/1e6;}}}' perl -pi -e 's/\bimport\(/require(/g' app.cjs
--external=@gpuix/native keeps the .node out of the JS bundle. The banner polyfills queueMicrotask and performance, which Hermes does not provide.
Confirm the rewrite:
rg -n '\bimport\(' app.cjs || echo 'no leftover import()'
\bimport\( does not touch import.meta. It only rewrites call expressions. The leftover calls are return import(e) with a runtime string. require(e) is valid CJS and is what hermes-node can run.

4. Run

hermes-node --no-compile-cache app.cjs
require('@gpuix/native') must resolve. Run from the app directory after bun add @gpuix/react, or set NODE_PATH to that node_modules.
NODE_PATH=./node_modules hermes-node --no-compile-cache app.cjs
A save does not remount. There is no bun --hot here. Rebuild app.cjs and restart.

5. Ship two files

hermes-node --build-bundle=dist/app.bundle app.cjs hermes-node --build-exe=dist/gpuix-hermes \ --kit=/path/to/hermes-node/cmake-build-release/kit \ dist/app.bundle
--build-bundle copies gpuix-native.<platform>.node next to the bundle. --build-exe links a new Mach-O (not a blob stuffed into a prebuilt runtime). The .node still sits beside the exe.
dist/ gpuix-hermes 12 MB linked Hermes + bytecode gpuix-native.darwin-arm64.node 22 MB sidecar, dlopen path
Run the pair:
./dist/gpuix-hermes
macOS release tarballs do not ship a kit yet. Pass --kit= from your build tree. Linux releases put kit/ next to the binary.

6. Wrap the pair in a .app

Finder and Dock only show an icon on a .app. Use cargo-packager for that. Config reference: Config. CLI: cargo-packager docs.
Install once:
cargo install cargo-packager --locked
Build an .icns from a 1024 PNG. Passing raw PNGs failed here with No matching IconType (1024 is not an ICNS slot):
mkdir AppIcon.iconset sips -z 16 16 icon-1024.png --out AppIcon.iconset/icon_16x16.png sips -z 32 32 icon-1024.png --out AppIcon.iconset/icon_16x16@2x.png sips -z 32 32 icon-1024.png --out AppIcon.iconset/icon_32x32.png sips -z 64 64 icon-1024.png --out AppIcon.iconset/icon_32x32@2x.png sips -z 128 128 icon-1024.png --out AppIcon.iconset/icon_128x128.png sips -z 256 256 icon-1024.png --out AppIcon.iconset/icon_128x128@2x.png sips -z 256 256 icon-1024.png --out AppIcon.iconset/icon_256x256.png sips -z 512 512 icon-1024.png --out AppIcon.iconset/icon_256x256@2x.png sips -z 512 512 icon-1024.png --out AppIcon.iconset/icon_512x512.png sips -z 1024 1024 icon-1024.png --out AppIcon.iconset/icon_512x512@2x.png iconutil -c icns AppIcon.iconset -o AppIcon.icns
List the .node as a second binary, not a resource. Resources land in Contents/Resources. Hermes dlopens next to the exe, which is Contents/MacOS.
{ "productName": "GPUIX Hermes", "version": "0.1.0", "identifier": "dev.gpuix.hermes", "binariesDir": "dist", "outDir": "bundle", "binaries": [ { "path": "gpuix-hermes", "main": true }, { "path": "gpuix-native.darwin-arm64.node", "main": false } ], "icons": ["AppIcon.icns"], "formats": ["app"] }
cargo packager --release --config packager.json open "bundle/GPUIX Hermes.app"
Measured on this machine: the .app is 34 MB. Inside:
GPUIX Hermes.app/Contents/ MacOS/gpuix-hermes 12 MB MacOS/gpuix-native.darwin-arm64.node 22 MB Resources/AppIcon.icns Info.plist
Change formats for the other OSes. cargo-packager only builds the host platform:
OSformatsWhat the user gets
macOS"app" then "dmg".app, optional .dmg
Windows"nsis"setup .exe, sidecar next to the binary
Linux"appimage"one .AppImage file
A Bun chat binary needs no sidecar. Compile first, then pack one binary: bun build --compile chat.tsx --outfile dist/chat, then the same cargo packager call with a single { "path": "chat", "main": true }. That .app was 82 MB here.

What does not go in the binary

hermes-node's bundle-natives design is explicit: native addons ship alongside, not inside.
dlopen(3) takes a path, not a buffer. There is no portable in-memory load of a shared object. Node SEA does not try. Deno and Bun extract to temp files. hermes-node copies a sidecar and records an empty kNative payload.
Do not wait for a one-file .node embed. It is a written non-goal.

Engine gaps

These are Hermes limits, not GPUIX:
  • No ESM. import / export / import() / "type": "module". Bundle to CJS.
  • No queueMicrotask / performance. The bun --banner covers both.
  • No crypto / tls / worker_threads. Unused by GPUIX.
  • No fetch. Hermes is a JS engine. Networking lives in the host. Use node-fetch@2.
  • NAPI v10 is implemented, including thread-safe functions.

Fetch

hermes-node has no global fetch. require('http') works. https and tls load, then throw: TLS is not implemented.
Use node-fetch@2. It is CJS. v3 is ESM and Hermes cannot parse it. node-fetch wraps Node http / https, so HTTPS still fails. Plain HTTP works.
bun add node-fetch@2
const fetch = require('node-fetch') const res = await fetch('http://example.com') const body = await res.text()
Measured on rebuilt hermes-node: http://example.com returned 200 and 559 bytes. https://example.com threw https is not supported (TLS not implemented).
Stay on node-fetch@2. Do not install v3. Bundle it into the CJS file, or require it at runtime. There is no native fetch to fall back to.
An ESM loader is planned on hermes-node as an AST lower to CJS (issue #3, draft 2026-08-25). It is not shipped. Even then, import() inside a CJS file stays invalid.
The Hermes engine has no public ESM roadmap (last word: May 2025).
Keep the perl rewrite even after that loader lands. It only runs on files the loader marks as ESM. A CJS bundle still cannot contain import().

Feasible?

Yes, with a rebuilt macOS hermes-node and a CJS bundle. Same @gpuix/native. No GPUIX Rust change.
Do not use the published macOS tarball until they drop -dead_strip or keep NAPI as a GC root (tmikov/hermes-node#11). Changing GPUIX cannot invent napi_* in a host that stripped them.