@gpuix/native
.node loads. The JS side must be CommonJS. Hermes has no ESM loader.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
.node addon. Rebuild
hermes-node without -dead_strip first. Linux releases keep napi_* via
-rdynamic. Tracked as tmikov/hermes-node#11.| 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 |
.node. Hermes itself is the 12 MB exe.
--build-exe cannot put the .node inside the Mach-O. dlopen needs a path..nodenapi build --platform --release
already uses [profile.release] lto = true. It does not pass --strip.
test-support is on by default so TestGpuixRenderer ships.| 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 |
cd packages/native bun run build:release -- --strip
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.test-support. The test renderer is small next to GPUI.__text, 1.5 MB unwind, 3 MB
__const, plus Metal / font-kit / syntect. That is the floor until GPUI itself
gets smaller.--strip is a
local opt-in. CI currently ships test-support on macOS and Windows.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 })
bun add @gpuix/react react
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
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
nm -gU cmake-build-release/bin/hermes-node | grep ' _napi_' | wc -l # expect 145, not 0
hermes-node on your PATH, or use the absolute path below..node
(different Team IDs). A local rebuild is ad-hoc without runtime. Sign both
files with the same Team ID if you ship.import only in module mode. hermes-node never turns that on.
import() is a syntax error: Invalid expression encountered.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.@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.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.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
bun --hot here. Rebuild app.cjs
and restart.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
./dist/gpuix-hermes
--kit= from your
build tree. Linux releases put kit/ next to the binary..app.app. Use
cargo-packager for that.
Config reference: Config.
CLI: cargo-packager docs.cargo install cargo-packager --locked
.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
.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"
.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
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 |
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.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..node embed. It is a written non-goal.import / export / import() / "type": "module". Bundle to CJS.queueMicrotask / performance. The bun --banner covers both.crypto / tls / worker_threads. Unused by GPUIX.fetch. Hermes is a JS engine. Networking lives in the host. Use node-fetch@2.fetch. require('http') works. https and
tls load, then throw: TLS is not implemented.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()
http://example.com returned 200 and
559 bytes. https://example.com threw https is not supported (TLS not
implemented).require it at runtime. There is no native fetch to fall back to.import() inside a CJS file stays invalid.import().hermes-node and a CJS bundle. Same
@gpuix/native. No GPUIX Rust change.-dead_strip
or keep NAPI as a GC root
(tmikov/hermes-node#11).
Changing GPUIX cannot invent napi_* in a host that stripped them.