< Back to all hacks

#08 Node.js 22 in proot

Runtime
Problem
dpkg broken in proot, can't install Node via apt. Termux native Node is v12.
Solution
Download pre-compiled Node.js 22.12.0 ARM binary from nodejs.org, extract directly.
Lesson
When package managers fail, a statically-linked (or self-contained) binary downloaded and extracted manually is the most reliable fallback.

Context

Termux's native Node.js package on the apt-android-5 repo is version 12, which is far too old for OpenClaw (requires Node 18+). Inside the proot Ubuntu environment, the standard approach would be apt install nodejs, but dpkg frequently breaks inside proot due to filesystem syscall emulation issues (fakeroot conflicts, dpkg lock failures, statx syscall not intercepted properly).

The solution is to skip package managers entirely and use the pre-compiled binary tarball from nodejs.org. Node.js provides official linux-armv7l builds that include the V8 engine, npm, and full ICU data. These run on the Moto E2's Snapdragon 410 (Cortex-A53 cores operating in 32-bit ARMv7 mode).

This hack is marked LEGACY. The proot Node.js was replaced by a custom-compiled node22-icu binary running natively on Termux (58 MB, stripped, NDK r26c, API 24 with small-icu). See hack 12 for the native compilation story.

Implementation

Download and extract Node.js 22 inside the proot rootfs:

# From inside proot (run-proot.sh)
cd /root

# Download the official ARM binary tarball
curl -L -o node-v22.12.0-linux-armv7l.tar.xz \
  "https://nodejs.org/dist/v22.12.0/node-v22.12.0-linux-armv7l.tar.xz"

# Verify the SHA256 checksum matches nodejs.org/dist/v22.12.0/SHASUMS256.txt
sha256sum node-v22.12.0-linux-armv7l.tar.xz

Extract and install to /usr/local:

# Extract the tarball
tar -xf node-v22.12.0-linux-armv7l.tar.xz

# Copy all contents to /usr/local (bin/, lib/, include/, share/)
cp -r node-v22.12.0-linux-armv7l/* /usr/local/

# Clean up the tarball and extracted directory
rm -rf node-v22.12.0-linux-armv7l node-v22.12.0-linux-armv7l.tar.xz

Verify the installation:

node --version
# v22.12.0

npm --version
# 10.x.x

# Confirm ICU is included (needed for Unicode property escapes \p{L} \p{N})
node -e "console.log(Intl.DateTimeFormat('fr').format(new Date()))"
# Should print a French-formatted date

# Confirm architecture
node -e "console.log(process.arch, process.platform)"
# arm linux

Set the V8 heap limit to survive on 1 GB RAM:

# In /root/.openclaw/env or the start script
NODE_OPTIONS="--max-old-space-size=128"

The binary can also be invoked from Termux through the proot helper:

# From Termux (outside proot)
run-proot.sh -c "node --version"
run-proot.sh -c "node /root/.openclaw/gateway/dist/index.js"

Verification

  • node --version inside proot returns v22.12.0.
  • node -e "console.log(process.arch)" returns arm.
  • node -e "console.log(/\p{L}/u.test('e'))" returns true (Unicode property escapes work, confirming ICU is present).
  • file /usr/local/bin/node shows ELF 32-bit LSB executable, ARM, EABI5.
  • npm install of a small package succeeds inside proot.
  • The OpenClaw gateway starts without "unsupported engine" or syntax errors.
  • node -e "console.log(process.versions)" shows v8, icu, unicode versions.

Gotchas

  • The linux-armv7l build is the correct one, not linux-arm64. Even though the Cortex-A53 supports ARMv8/AArch64, the Moto E2 runs a 32-bit Android kernel and userspace. Using the arm64 binary results in exec format error.
  • Node.js 22 binaries from nodejs.org include full ICU by default. This is critical because OpenClaw's codebase uses \p{L} and \p{N} Unicode property escapes in regular expressions. A --without-intl build would crash with "Invalid regular expression" errors at runtime.
  • The V8 heap limit inside proot should be 128 MB maximum. The live heap peaks around 93-105 MB during gateway startup. Setting --max-old-space-size=96 causes OOM. The native migration later proved 112 MB is the true minimum (128 had been used in proot with some margin).
  • proot adds syscall overhead to every filesystem operation. Node.js startup is noticeably slower inside proot (roughly 2-3x) compared to native execution. Module resolution, which makes many stat() calls, is particularly affected.
  • The tarball extracts a node-v22.12.0-linux-armv7l/ directory containing bin/, lib/, include/, and share/. Using tar -xf without --strip-components=1 -C /usr/local creates a nested directory. The cp -r approach shown above is more explicit.
  • npm's global prefix inside proot defaults to /usr/local. This works because Node was extracted there. If extracted elsewhere, npm config set prefix /path/to/node is needed.
  • The full ICU build adds roughly 25 MB to the binary size. The later native build used --with-intl=small-icu to save space while still supporting \p{L} patterns.

Result

Node.js 22.12.0 runs inside proot on the Moto E2, providing a modern JavaScript runtime capable of running OpenClaw. The binary download approach completely bypasses all package manager issues. Combined with the run-proot.sh helper (hack 7) and V8 heap limits, this gave the phone a working OpenClaw gateway at roughly 170 MB RSS (including proot overhead). The native migration later reduced this to 155 MB by eliminating proot entirely and using a custom-compiled binary with small-icu.