FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat: run the backend on nodejs-mobile 24 (Node 24.19.0) by gmaclennan · Pull Request #230 · digidem/comapeo-core-react-native · GitHub

feat: run the backend on nodejs-mobile 24 (Node 24.19.0) - #230

Merged
gmaclennan merged 6 commits into
mainfrom
chore/nodejs-mobile-24
Aug 18, 2026
Merged

feat: run the backend on nodejs-mobile 24 (Node 24.19.0)#230
gmaclennan merged 6 commits into
mainfrom
chore/nodejs-mobile-24

Conversation

gmaclennan commented Aug 17, 2026
edited
Loading

Copy link
Copy Markdown
Member

Moves the embedded runtime from nodejs-mobile v18.20.4 to the digidem fork's
v24.19.0-0 — Node 24.19.0, V8 13.6, ABI 137 — on the lite flavour.

What the upgrade forces

--no-experimental-fetch is gone from the iOS argv. Node removed the flag in
23, and an unknown flag aborts before any JS runs, so this is not optional.
It was there because nodejs-mobile's jitless iOS V8 had no WebAssembly and
undici calls WebAssembly.compile at module init. The 24.x build serves
WebAssembly from a polyfill inside the runtime itself — I confirmed polywasm
is in the shipped bytes on both platforms — so fetch works natively and the
whole iOS-only shim stack goes with it: index.ios.js, the polywasm and undici
installers, the SIMD-wasm alias, and the loader-entry redirect. Both platforms
now bundle from one entry and differ only in the __loadAddon banner.

better-sqlite3 is pinned to 13.0.3 through overrides. 11.10.0 cannot be
compiled against V8 13.6 at all — it uses the ObjectTemplate::SetAccessor
overload that was removed. 12.10.0 compiles, but aborts on real devices: the
backend reaches ready and dies ~86ms later in
node::RemoveEnvironmentCleanupHook (Assertion failed: (env) != nullptr),
which the BrowserStack run caught and no local test did. It was the only shipped
addon importing the raw V8 embedder API.

13.0.0 migrated to N-API, so the shipped binary has zero V8 symbols and zero
cleanup-hook references — the crash class is gone by construction. It also stops
being ABI-locked, so one artifact serves every Node version; usesNapi flips and
the prebuild URL loses its -node-<abi> infix. 13.x dropped bindings for its
own lib/binding.js resolver, which only knows linux/darwin/win32 paths, so the
addon-loader gains a pattern for it plus a build-time error if that specific
resolver ever goes unrewritten. The other six addons were already N-API and carry
over untouched.

Why lite

The v18 libnode.so reports zero ICU symbols and no icudt data — it was
already --with-intl=none. So full would add an Intl this backend has
never had, and lite is the parity choice rather than a reduction. Checked
against the actual bundle: the surviving Intl.DateTimeFormat reference is a
Sentry helper that returns early unless process.versions.icu is set; the
Intl.Collator/Intl.NumberFormat ones are mapbox style-spec expression
evaluators whose exposure is identical to v18 today. Every node:inspector
reference is an await import() inside a try/catch or an opt-in Sentry
integration, there is no node:sqlite, and we ship plain JS.

Prebuilds are flavour-neutral: lite and full ship byte-identical headers and
export the same 1538 V8 symbols, and all 76 symbols the better-sqlite3 prebuild
imports resolve against lite's libnode.

Sizes, against the v18 build being replaced rather than against full:

v18 v24 full v24 lite
android arm64 61 MB 83 MB 65 MB
android armeabi-v7a 57 MB 76 MB 59 MB
android x86_64 63 MB 82 MB 64 MB
iOS device slice 53 MB 61 MB 44 MB

Environment node inherits

The 24.x release is the first where an embedder-set TMPDIR is actually
readable on Android — an app process is forked from the zygote without
exec(), so upstream's SafeGetenv() privilege heuristic used to decline the
whole group. An Android app process has no TMPDIR and there is no /tmp,
which is exactly where os.tmpdir() falls back to, so anything writing there
failed with ENOENT. It now points at a real directory under cacheDir, set
through a new JNI setenv bridge because node reads it while creating the
Environment — assigning process.env from JS is too late.

V8's on-disk code cache is enabled the same way, via NODE_COMPILE_CACHE
(plus NODE_COMPILE_CACHE_PORTABLE). Env var rather than
module.enableCompileCache() deliberately: the env var is read at Environment
creation, so it covers loader.mjs itself and the Sentry chunk, which are the
two largest compiles on the boot path and both run before any of our JS could
call the runtime API.

The backend flushes it explicitly once boot reaches ready, instead of leaving
it to node's exit handler. That hook is the one moment mobile denies us — the
low-memory killer takes :ComapeoCore outright, and iOS usually kills the app
while suspended without ever reaching applicationWillTerminate — so a cache
that only landed on a clean exit would rarely land at all. By ready
everything on the boot path, which is the set worth caching, has compiled. It
measures ~3.7 MB across 14 files, written once per install; later boots are
cache hits and rewrite nothing.

Also in here

  • readNodeJsMobileVersions() matched NODE_MODULE_VERSION (.+). Node 24's
    header defines that macro twice and the first is the
    NODE_EMBEDDER_MODULE_VERSION passthrough, so the old pattern would have put
    a garbage ABI into every prebuild URL.
  • android/CMakeLists.txt gains CXX_STANDARD 20. Verified in both
    directions: Node 24's v8config.h emits "C++20 or later required." under
    the NDK's default gnu++17, and compiles clean with the flag.
  • The 24.x NodeMobile.xcframework has no x86_64 simulator slice, so that
    prebuild target and the lipo pass that merged the two simulator binaries
    are gone. Intel Macs can't run the simulator against this runtime either way.
  • Sentry events carry the mobile revision as a nodejs_mobile tag;
    contexts.runtime only ever had the upstream Node version.

Verified on device

Android emulator (Pixel_7a_API_29, arm64): 11/11 app-level tests
(ServiceLifecycleTest, ShutdownPathTest, which spawn :ComapeoCore and boot
Node) plus 26 module tests. iOS simulator (iPhone 16): 8/8 XCTest including
testFullServiceLifecycle. Both logs show the runtime identifying itself as
v24.19.0, both sockets listening, and exitCode=0 on shutdown.

The new environment plumbing is visible working rather than merely wired up:

setEnv: TMPDIR=/data/user/0/<pkg>/cache/tmp
setEnv: NODE_COMPILE_CACHE=/data/user/0/<pkg>/cache/node-compile-cache
setEnv: NODE_COMPILE_CACHE_PORTABLE=1
Compile cache flushed to .../node-compile-cache/v24.19.0-arm64-cf738c9d-10247

And it pays for itself. Across 12 consecutive boots on the emulator, measured
from the setEnv line (i.e. after asset extraction, so this is module compile
time rather than first-run noise) to the first JS line:

cold (boot 1) warm (boots 2-12, median)
to first JS 0.78s ~0.35s
to ready 1.58s ~0.67s

Time to first JS halves. That is also the argument for flushing at ready
rather than at exit: the cache was written on boot 1 and paid off from boot 2,
across separate process spawns. A low-end device should see a larger absolute
saving than this M2 emulator.

One thing this turned up, filed separately as #231 and not caused by this
PR: shutdown: control close failed ERR_STREAM_WRITE_AFTER_END on every
shutdown, both platforms. It is not cosmetic — the stopping control frame is
never delivered at all (13 started, 12 ready, 0 stopping across the
emulator runs), so the graceful-shutdown signal the design added has never
fired. broadcast() queues the frame, streamx defers the socket write to the
next tick, and close() ends the socket synchronously in that same tick.

It reproduces identically on Node 18.20.4, 20, 22 and 24 against this repo's own
framed-stream/streamx versions, those versions are unchanged from main,
and this branch touches none of server-helper.js, message-port.js or
simple-rpc.js. It dates from #47.

Depends on

All landed:

Follow-ups, not in scope here

  • --enable-source-maps is still off. The 320 ms / 250–470 MB-per-error
    measurement behind that decision was taken on Node 18; node has since
    reworked its source-map cache, so it's worth re-measuring on device.
  • --max-old-space-size-percentage, which this runtime adds, is the documented
    lever for capping V8's old space on devices where exceeding the cgroup limit
    kills the whole app. Relevant to the :ComapeoCore LMK work, but it changes
    memory behaviour and belongs in its own change.
  • node:sqlite now exists in the full flavour and could eventually displace
    the better-sqlite3 addon — the one dependency that costs us a per-Node-
    version prebuild.

Moves the embedded runtime from nodejs-mobile v18.20.4 to the digidem
fork's v24.19.0-0. The fork versions releases `<node>-<mobile-rev>` and
renamed its assets, so the download script changes shape as well as
version.

Two things the upgrade forces rather than merely allows:

`--no-experimental-fetch` is gone from iOS argv — Node removed the flag
in 23, and passing an unknown flag aborts before any JS runs. The iOS
build now serves WebAssembly through a polyfill inside nodejs-mobile
itself and has a working `fetch`, so the whole iOS-only shim stack goes
with it: `index.ios.js`, the polywasm/undici installers, the SIMD-wasm
alias and the loader-entry redirect. Both platforms now bundle from one
entry, differing only in the `__loadAddon` banner.

better-sqlite3 11 doesn't compile against V8 13.6 (Node 24 dropped the
`ObjectTemplate::SetAccessor` overload it uses), and as a raw V8 addon it
needs an ABI-matched prebuild per Node version, so the tree is pinned to
one 12.10.0 through `overrides` and consumes the new ABI 137 prebuilds.

Also picked up along the way:

- TMPDIR now points at a real directory. An Android app process has none
  and there is no `/tmp`, which is where `os.tmpdir()` otherwise lands;
  reading it needed the credentials fix that arrived with this release.
- V8's on-disk code cache is enabled via `NODE_COMPILE_CACHE`. Env var
  rather than `module.enableCompileCache()` so it covers `loader.mjs` and
  the Sentry chunk, which compile before any of our JS could call the
  runtime API. The backend flushes it at `ready` instead of leaving it to
  node's exit hook — the low-memory killer and iOS's suspended-app kill
  both skip that hook, so the cache would rarely be written at all.
- The 24.x NodeMobile.xcframework has no x86_64 simulator slice, so the
  x64-simulator prebuild leg and its `lipo` pass are gone.
- `readNodeJsMobileVersions()` matched `NODE_MODULE_VERSION (.+)`, which
  on the Node 24 header hits the `NODE_EMBEDDER_MODULE_VERSION`
  passthrough first and yields a garbage ABI in prebuild URLs.
- Node 24's `v8config.h` #errors below C++20; the NDK defaults to gnu++17.
- Sentry events carry the mobile revision as a `nodejs_mobile` tag;
  `contexts.runtime` only has the upstream Node version.
`lite` drops ICU, the inspector, `node:sqlite` and TypeScript type-stripping,
and on iOS also V8's compiled tiers — dead weight there, since it runs jitless
and serves WebAssembly through the bundled polyfill.

None of it is a loss. The v18 build we came from reports 0 ICU symbols and no
`icudt` data, i.e. it was already `--with-intl=none`, so `full` would *add* an
`Intl` this backend has never had; the two `Intl` references that survive
bundling are a Sentry helper that returns early unless `process.versions.icu`
is set, and mapbox style-spec expression evaluators whose exposure is unchanged
from v18. We use the `better-sqlite3` addon rather than `node:sqlite`, ship
plain JS, and every `node:inspector` reference in the bundle is an
`await import()` inside a try/catch or an opt-in Sentry integration.

Addon prebuilds are flavour-neutral: both flavours ship byte-identical headers
and export the same V8 symbol set, and every symbol our better-sqlite3 prebuild
imports resolves against the lite `libnode`.

Sizes against the v18 build this replaces, rather than against full:

  android arm64    61 -> 65 MB
  android armv7a   57 -> 59 MB
  android x86_64   63 -> 64 MB
  ios device slice 53 -> 44 MB
gmaclennan added the run-e2e Run the full BrowserStack e2e on this PR (otherwise it runs only in the merge queue) label Aug 17, 2026
github-actions Bot added the feature New feature (changelog) label Aug 17, 2026

socket-security Bot commented Aug 17, 2026
edited
Loading

Copy link
Copy Markdown

All alerts resolved. Learn more about Socket for GitHub.

This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored.

View full report

12.10.0 built against Node 24 crashes on real hardware. The backend reaches
ready and aborts ~86ms later, on every boot:

  # node[2591]: node::RemoveEnvironmentCleanupHook(...) at ../src/api/hooks.cc:142
  # Assertion failed: (env) != nullptr
  Fatal signal 6 (SIGABRT)

Everything downstream in the e2e run is fallout — RPC_TIMEOUT, socket connect
timeouts, `device-id` never rendering. `libbetter-sqlite3__12.10.0.so` was the
only shipped addon importing `Add`/`RemoveEnvironmentCleanupHook`, the raw V8
embedder API; the other six are N-API and import none of it.

13.0.0 migrated to N-API. The shipped binary now has zero V8 symbols and zero
cleanup-hook references, so the crash class is gone by construction rather than
patched. It also stops being ABI-locked: one artifact serves every Node
version, which is why `usesNapi` flips and the prebuild URL loses its
`-node-<abi>` infix.

13.x dropped `bindings` for its own `lib/binding.js`, which knows only
linux/darwin/win32 prebuild paths before falling back to node-gyp build dirs —
none of which exist on device — so the addon-loader gains a pattern for it.
Because every pattern here matches upstream source text, a miss would silently
ship a resolver that only fails on a device; the plugin now fails the build
instead when that specific resolver goes unrewritten.

Verified: 11/11 Android lifecycle tests and 8/8 iOS XCTest, 12 clean boots,
zero occurrences of the crash signature.
Removing this alias alongside the polywasm/fetch installers was wrong. Those
installers are genuinely redundant now — the runtime supplies `WebAssembly`
and `fetch` — but the alias targets a different thing: the npm `undici@6` this
backend bundles, not Node's built-in undici.

nodejs-mobile 24's bootstrap sets `UNDICI_NO_WASM_SIMD=1` to steer undici off
the SIMD build of llhttp, but that env var is an undici 7.x feature, so the
bundled 6.23.0 copy ignores it and calls
`WebAssembly.compile(llhttp_simd-wasm)` unconditionally. polywasm compiles
function bodies lazily, so that compile succeeds and then throws
`Unsupported instruction: 0xFD` on the first parser callback — past the
try/catch undici wraps the compile in.

`@comapeo/core`'s maps plugin and `secret-stream-http` (via
`@comapeo/map-server`) both import `fetch` from that bundled copy, so this
covers online map styles and peer blob/SMP fetches. Verified: the SIMD payload
is present in both bundles without the alias, and absent from the iOS bundle
with it.

The e2e suite could not have caught this — its map-server tests use the
built-in fallback map only, with no project, no uploaded SMP and no network.

Also from review: don't set TMPDIR/NODE_COMPILE_CACHE to a directory we failed
to create (and check `isDirectory`, since `mkdirs()` returns false when the
directory already exists); correct the JNI comment, which claimed TMPDIR must
be set before startup — only NODE_COMPILE_CACHE must; drop stale `lipo` and
non-NAPI references.
…bundle

The redirect that keeps undici's SIMD llhttp away from polywasm matches an
upstream import specifier, so an undici reshuffle turns it into a silent no-op
and the SIMD bytes come back. Nothing downstream notices: the failure needs a
real network fetch on a jitless device, and the e2e map-server tests
deliberately use the built-in fallback map with no project, no uploaded SMP and
no network. That is why removing the redirect went unnoticed in the first
place.

Assert the outcome rather than the mechanism — after writing the iOS bundle,
fail if it still contains the SIMD payload. The marker is the first slice of
that module's base64 that differs from its non-SIMD sibling, so it identifies
the specific wasm build without depending on module names surviving
minification. No undici installed means nothing to assert, which is the right
answer if the dependency ever goes away (see #232).

Verified in both directions: the build passes as-is, and fails with an
actionable message when the redirect is made not to match.
Comment thread ios/AppLifecycleDelegate.swift Outdated
// regenerable data wants. TMPDIR and HOME are already set by iOS.
if let compileCacheDir = AppLifecycleDelegate.resolveCompileCacheDir() {
setenv("NODE_COMPILE_CACHE", compileCacheDir, 1)
setenv("NODE_COMPILE_CACHE_PORTABLE", "1", 1)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

as with android, is the portable option necessary?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Choose a reason Spam Abuse Off Topic Outdated Duplicate Resolved Low Quality

Dropped here too — same reasoning as the Android one, see that thread for the measurement.

- Drop NODE_COMPILE_CACHE_PORTABLE on both platforms. It keys cache entries
  by path relative to the cache dir, which only helps when the modules and the
  cache move together. Ours never do: the cache is in cacheDir/Library Caches
  and the JS is in filesDir/the app bundle, so the relative path still contains
  the varying component. Verified by moving the module dir between runs — the
  entry hash changes either way, so the setting bought nothing.

- Report a failure to create either directory to Sentry via `logCapture`, the
  existing helper for notable non-exception events, rather than a local log
  line. A device that can never write there silently pays the cold-compile cost
  on every launch, so the rate is worth seeing.

- Trim the addon-loader comments to what a maintainer of the current code
  needs: no better-sqlite3 version history, and no restating between the file
  header and the pattern it describes.
gmaclennan enabled auto-merge August 18, 2026 11:57
gmaclennan added this pull request to the merge queue Aug 18, 2026
Merged via the queue into main with commit 59ae404 Aug 18, 2026
24 checks passed
gmaclennan deleted the chore/nodejs-mobile-24 branch August 18, 2026 12:27
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature (changelog) run-e2e Run the full BrowserStack e2e on this PR (otherwise it runs only in the merge queue)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant


Back | FazBrowse Home | New Git URL