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