---
$schema: https://holocron.so/frontmatter.json
title: Run GPUIX on hermes-node for a smaller binary
sidebarTitle: Hermes
description: Ship a GPUIX React app on hermes-node instead of Bun or Node. The result is a 12 MB executable plus a 22 MB native sidecar.
icon: lucide:cpu
prompt: |
Write a guide for shipping a GPUIX React app on hermes-node instead of Bun or Node.
Sources: @/hermes/, @/README.md, @/packages/native/,
@https://github.com/tmikov/hermes-node,
@https://raw.githubusercontent.com/facebook/hermes/static_h/API/napi/README.md,
@https://raw.githubusercontent.com/facebook/hermes/static_h/doc/Features.md,
@https://github.com/tmikov/hermes-node/blob/master/docs/superpowers/specs/2026-08-21-bundle-natives-design.md,
@https://github.com/tmikov/hermes-node/blob/master/docs/superpowers/specs/2026-08-23-single-executable-design.md.
Cover rebuild of hermes-node on macOS (dead_strip drops napi_*, tmikov/hermes-node#11), bun build --format=cjs,
measured 12 MB exe + 22 MB .node sidecar, wrapping both files in a .app with
cargo-packager, shrinking the .node with napi --strip / strip -x to 17 MB,
and that dlopen cannot load a .node from inside the binary.
@https://github.com/crabnebula-dev/cargo-packager
@https://docs.rs/cargo-packager/latest/cargo_packager/
@https://docs.rs/cargo-packager/latest/cargo_packager/config/struct.Config.html
---
**hermes-node** is a Node-compatible runtime on [Hermes](https://github.com/facebook/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.
```diagram
bun build --format=cjs
app.tsx ──────────────────────────────────────────► app.cjs
React + GPUIX │
│ process.dlopen
▼
@gpuix/native ► GPUI ► GPU
```
## Size
Measured on **macOS arm64**, Release, GPUIX counter:
| Artifact | Size |
| ------------------------------------- | --------- |
| `hermes-node` runtime | 11 MB |
| `app.cjs` (React + GPUIX, production) | 531 KB |
| `app.bundle` (Hermes bytecode) | 266 KB |
| **`gpuix-hermes` executable** | **12 MB** |
| **`gpuix-native.darwin-arm64.node`** | **22 MB** |
| **exe + native, the ship set** | **33 MB** |
| `bun build --compile` chat, for scale | 83 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:
| Step | Size |
| -------------------------------------- | --------------------------------- |
| stock `gpuix-native.darwin-arm64.node` | **22 MB** |
| `strip -x` (local symbols only) | **17 MB** |
| `strip` with no `-x` | fails. NAPI / AppKit imports stay |
Rebuild without the test renderer:
```bash
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.
## 1. Write a normal GPUIX app
Same React entry as Bun. End the file with **`render()`**.
```tsx
import { useState } from 'react'
import { render } from '@gpuix/react'
function App() {
const [count, setCount] = useState(0)
return (
setCount((c) => c + 1)}
>
{String(count)}
)
}
render(, { title: 'GPUIX Hermes', width: 480, height: 320 })
```
Install from a folder that can resolve workspace packages, or from a real app:
```bash
bun add @gpuix/react react
```
## 2. Rebuild hermes-node on macOS
[hermes-node](https://github.com/tmikov/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](https://github.com/tmikov/hermes-node/issues/11).
```bash
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.
```bash
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:
```bash
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.
## 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/`:
```bash
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:
```bash
rg -n '\bimport\(' app.cjs || echo 'no leftover import()'
```
## 4. Run
```bash
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`.
```bash
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
```bash
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..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.
```diagram
dist/
gpuix-hermes 12 MB linked Hermes + bytecode
gpuix-native.darwin-arm64.node 22 MB sidecar, dlopen path
```
Run the pair:
```bash
./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](https://github.com/crabnebula-dev/cargo-packager) for that.
Config reference: [Config](https://docs.rs/cargo-packager/latest/cargo_packager/config/struct.Config.html).
CLI: [cargo-packager docs](https://docs.rs/cargo-packager/latest/cargo_packager/).
Install once:
```bash
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):
```bash
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 `dlopen`s next to the exe, which is
`Contents/MacOS`.
```json
{
"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"]
}
```
```bash
cargo packager --release --config packager.json
open "bundle/GPUIX Hermes.app"
```
Measured on this machine: the `.app` is **34 MB**. Inside:
```diagram
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:
| OS | `formats` | What the user gets |
| ------- | -------------------- | ---------------------------------------- |
| macOS | `"app"` then `"dmg"` | `.app`, optional `.dmg` |
| Windows | `"nsis"` | setup `.exe`, sidecar next to the binary |
| Linux | `"appimage"` | one `.AppImage` file |
## What does not go in the binary
[hermes-node's bundle-natives design](https://github.com/tmikov/hermes-node/blob/master/docs/superpowers/specs/2026-08-21-bundle-natives-design.md)
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](https://www.npmjs.com/package/node-fetch)**. It is CJS.
v3 is ESM and Hermes cannot parse it. `node-fetch` wraps Node `http` /
`https`, so **HTTPS still fails**. Plain HTTP works.
```bash
bun add node-fetch@2
```
```js
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)`.
An ESM loader is **planned** on hermes-node as an AST lower to CJS
([issue #3](https://github.com/tmikov/hermes-node/issues/3), draft 2026-08-25).
It is not shipped. Even then, `import()` inside a **CJS** file stays invalid.
The [Hermes engine](https://github.com/facebook/hermes/discussions/1391) has
no public ESM roadmap (last word: May 2025).
## 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](https://github.com/tmikov/hermes-node/issues/11)).
Changing GPUIX cannot invent `napi_*` in a host that stripped them.