Skip to content

iOS Phase 2: xcframework Embed & Sign for native addons#16

Merged
gmaclennan merged 15 commits into
mainfrom
claude/ios-phase-2-xcframework
Apr 28, 2026
Merged

iOS Phase 2: xcframework Embed & Sign for native addons#16
gmaclennan merged 15 commits into
mainfrom
claude/ios-phase-2-xcframework

Conversation

@gmaclennan

Copy link
Copy Markdown
Member

Summary

Phase 2 of docs/build-architecture-plan.md, narrowed to iOS only — Android jniLibs/ migration is a sibling PR. Replaces Phase 1's runtime asset overlay (AppLifecycleDelegate.prepareNodeBundle() merging nodejs-native/<arch>/ over nodejs-project/) with one <name>.xcframework per native addon, embedded by Xcode's standard Embed & Sign phase. Native .node files now load via process.dlopen against <App>.app/Frameworks/ — zero runtime extraction, codesigned for free, device + simulator both supported.

Six commits, one logical step each (review them in order):

  1. Plan doc (docs/phase-2-xcframework-plan.md) — scope decisions, recipe, risks, acceptance criteria.
  2. scripts/build-backend.ts — emit ios/Frameworks/<name>.xcframework per addon (device arm64 + lipo'd simulator). Drops ios/nodejs-native/. Recipe lifted from digidem/nodejs-mobile-bare-prebuilds@feat/jnilibs-xcframework-packaging's validated harness, plus install_name_tool -id @rpath/... (which the harness skipped — see commit message).
  3. backend/rollup-plugins/rollup-plugin-ios-addon-loader.js — iOS-only plugin that rewrites the three loader patterns (require('bindings')(...), require('node-gyp-build')(...), require.addon(...)) to __loadAddon(name). Helper injected via output.banner. Android keeps the existing rollup-plugin-native-paths.
  4. backend/patches/@comapeo+core+7.1.0.patch + create-comapeo wiring — patch-package patch adding betterSqlite3NativeBinding option to MapeoManager/MapeoProject. better-sqlite3 already supports nativeBinding; we just thread it through. The hyphen/underscore mismatch in the .node filename and the lazy-load behaviour both go away. Naming mirrors better-sqlite3's own nativeBinding option for discoverability.
  5. iOS wiringComapeoCore.podspec switches to s.vendored_frameworks = ['NodeMobile.xcframework', 'Frameworks/*.xcframework'] + drops nodejs-native from s.resources. AppLifecycleDelegate exports NATIVE_LIB_DIR=$bundlePath/Frameworks before NodeMobileStartNode, and prepareNodeBundle() collapses to a JS-only copy (no overlay, no arch picker — mergeDirectory() deleted).
  6. CI — new device-build job runs xcodebuild build -sdk iphoneos with CODE_SIGNING_ALLOWED=NO. Build-only, no run; a successful build proves dyld can resolve every @rpath/<name>.framework/<name> install name and Apple's Embed & Sign accepts every framework structure.

Test plan

  • npm run backend:build produces ios/Frameworks/<name>.xcframework for all 7 native modules (device + fat simulator slices).
  • otool -D on each framework binary returns @rpath/<name>.framework/<name>.
  • Local xcodebuild test on iPhone 16 / iOS 26.2: 4/4 integration tests pass — CoreManagerSmokeTest, ServiceLifecycleTest, both ComapeoCoreModuleTests. better-sqlite3 loads via the patched nativeBinding path; sodium-native + the other holepunch modules load via __loadAddon against NATIVE_LIB_DIR.
  • CI integration tests pass on simulator (pending push).
  • CI device-build job passes (pending push) — first run of the new job.
  • Android tests still pass (pending push) — confirms iOS-only scope did not regress the platform we're not touching.

Out of scope

  • Android jniLibs/ migration (sibling PR).
  • NodeJSService.kt JNI stdio pump drain fix (canonical §0.4).
  • Real-device smoke test (xcodebuild test on iOS hardware).
  • Version-stamp gate on prepareNodeBundle() cold-start copy (Phase 2.5).
  • iOS map-tile fetching, globalThis.fetch polyfill, maps-stub console.warn (tracked in unified-js-bundle-ios-plan.md §7).

🤖 Generated with Claude Code

gmaclennan and others added 6 commits April 28, 2026 13:43
Companion to docs/build-architecture-plan.md §5 Phase 2. Narrows the
first iteration to iOS — Android jniLibs migration is a sibling PR — so
the diff stays reviewable and Android isn't put at risk by changes that
only benefit iOS. Captures: scope decisions, xcframework wrapping recipe
lifted from the validated harness in
digidem/nodejs-mobile-bare-prebuilds@feat/jnilibs-xcframework-packaging,
the @comapeo/core nativeBinding patch shape, the iOS-only rollup loader
rewrite, NATIVE_LIB_DIR plumbing, AppLifecycleDelegate simplification,
device-arch CI verification, and acceptance criteria.

Updated as Phase 2 work landed: install_name_tool -id is required after
all (the harness skipped it because its test apps dlopen directly
without Embed & Sign, but our Frameworks/-embedded layout has dyld
walk LC_LOAD_DYLIB before our code runs); skipped versioned framework
names since the seven native modules ship at single versions today.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the Phase 1 ios/nodejs-native/<arch>/ resource bundle (extracted
at runtime by AppLifecycleDelegate.prepareNodeBundle) with one
ios/Frameworks/<name>.xcframework per native addon, embedded by Xcode's
standard Embed & Sign phase. Recipe lifted from the validated harness
in digidem/nodejs-mobile-bare-prebuilds@feat/jnilibs-xcframework-packaging:

  1. For each iOS arch (arm64 device + arm64-simulator + x64-simulator),
     copy the .node binary as the framework's Mach-O exec inside
     <work>/<arch>/<name>.framework/<name>, write a minimal Info.plist,
     and rewrite the install name with `install_name_tool -id
     @rpath/<name>.framework/<name>`. Without the install-name rewrite
     the framework's LC_ID_DYLIB stays at the upstream prebuild's
     `<name>.node`, dyld walks LC_LOAD_DYLIB at app launch, fails to
     resolve, and the app aborts before any code runs. The harness
     gets away without this because its test apps dlopen directly;
     our Frameworks/-embedded layout doesn't.

  2. lipo the two simulator binaries into one fat Mach-O so a single
     simulator slice covers both Apple Silicon and Intel hosts.

  3. xcodebuild -create-xcframework with the device framework + the
     lipo'd simulator framework -> ios/Frameworks/<name>.xcframework.

Adds ios-arm64 (device) to IOS_ARCHS — Phase 1 was simulator-only.
Versioned framework names are deliberately deferred (one version per
addon today; mechanical to add when a multi-version graph appears).

.gitignore retains ios/nodejs-native/ as a guard rail in case a stale
tree from an older branch ever lands locally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New rollup-plugin-ios-addon-loader.js, applied to the iOS bundle only
in backend/rollup.config.js. Rewrites the same three loader patterns
rollup-plugin-native-paths catches (require('bindings'), require('node-
gyp-build'), require.addon) but to a single `__loadAddon(name)` call,
not the bare-resolver path walks the Android bundle keeps.

The runtime helper itself ships via output.banner so it lives at the
top of the rolled-up file and is callable from the very first
module-level loader invocation. Implementation:

  process.dlopen(mod, NATIVE_LIB_DIR + '/' + name + '.framework/' + name)

NATIVE_LIB_DIR is exported by Swift in AppLifecycleDelegate's
nodeEntryPoint closure (see following commit) before NodeMobileStartNode
returns control to V8. Cached in an addon-local Map so repeat
__loadAddon('sodium-native') calls don't re-dlopen.

`globalThis.__loadAddon = __loadAddon` makes the helper reachable from
unbundled code (specifically backend/lib/create-comapeo.js after the
@comapeo/core patch) without threading another shim through.

Plugin is iOS-only: the Android bundle stays on rollup-plugin-native-paths
because Phase 1's asset-extraction path still works on Android. Android's
own jniLibs migration is a sibling PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
better-sqlite3 differs from the holepunch-style addons in two ways
that break the rollup-rewrite-only path used for the others:

  1. Lazy load. `import Database from 'better-sqlite3'` runs at module
     init, but the require('bindings')('better_sqlite3.node') call
     inside database.js#L48 only fires the first time
     `new Database(...)` runs. The rollup rewrite still fires (the dead
     branch is in the bundle), but the runtime path through bindings
     would be wrong — see point 2.

  2. Underscore vs hyphen. The .node binary is named better_sqlite3.node
     (underscore), not the package name. Our xcframework lands at
     better-sqlite3.framework/better-sqlite3 (hyphen, matching the npm
     package name); the original `require('bindings')('better_sqlite3.node')`
     lookup has no chance of reaching it.

better-sqlite3@11.10.0 already has built-in support for externally-
loaded addons via the `nativeBinding` constructor option
(database.js#L36-56): pass an addon module object and the internal
require('bindings') call is bypassed entirely. Patch @comapeo/core
to accept a new `betterSqlite3NativeBinding` option on MapeoManager
and thread it down to MapeoProject so per-project DBs reuse the same
binding. backend/lib/create-comapeo.js populates it via
`globalThis.__loadAddon?.('better-sqlite3')` — undefined on Android
(falls back to the existing bindings path), the dlopen'd addon on iOS.

Naming `betterSqlite3NativeBinding` mirrors better-sqlite3's own
`nativeBinding` option name so an upstream-docs reader recognises it
instantly; the package-name prefix disambiguates against any future
native bindings @comapeo/core might want to accept.

Mechanism: patch-package. backend/patches/@comapeo+core+7.1.0.patch is
checked into source control and applied by an explicit `npx patch-package`
step in scripts/build-backend.ts (the surrounding `npm ci --ignore-scripts`
skips patch-package's normal postinstall hook). Surfaces to anyone
running `npm install` in backend/ outside the build pipeline too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…IB_DIR

Three iOS-side changes to consume the Phase 2 build pipeline output:

ComapeoCore.podspec:

  - vendored_frameworks now globs `Frameworks/*.xcframework` alongside
    the existing NodeMobile.xcframework. CocoaPods picks each one up
    and Xcode's standard Embed & Sign phase populates
    <App>.app/Frameworks/ + codesigns at build time.
  - resources drops 'nodejs-native' (the Phase 1 per-arch .node
    bundle); only 'nodejs-project' remains for the rolled-up JS.

AppLifecycleDelegate.nodeEntryPoint:

  - setenv("NATIVE_LIB_DIR", <bundlePath>/Frameworks, 1) before
    NodeMobileStartNode. The rolled-up backend reads this env var (see
    rollup-plugin-ios-addon-loader.js) and process.dlopens
    <NATIVE_LIB_DIR>/<name>.framework/<name> for each native addon.

AppLifecycleDelegate.prepareNodeBundle:

  - Drops the nodejs-native overlay step (and the helper
    mergeDirectory() it called). Native code lives in
    <App>.app/Frameworks/ now; only the JS tree needs to be staged
    into Application Support so nodejs-mobile has a writable filesystem
    path for the entry script + drizzle migrations. The Phase 1
    `#if arch(arm64) ... #else ...` arch slice picker is gone — the
    arch dimension moved into the xcframework itself.

Phase 2.5 backlog item: gate the cold-start copy on a CFBundleVersion
stamp so it only runs when the bundle changed. Today the copy is
unconditional (~2 MB JS tree + drizzle SQLs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2 ships device + simulator slices in one xcframework per addon.
The integration tests still exercise the simulator slice (unchanged
from Phase 1 except for the underlying loader path); a new
device-build job verifies the device slice actually links and
codesigns. CI runners can't run on real iOS hardware, so we only
xcodebuild build (not test) against generic/platform=iOS — a
successful build proves dyld can resolve every
@rpath/<name>.framework/<name> install name and Apple's Embed & Sign
phase doesn't reject any framework's structure.

Same Xcode/macos versions as the integration job: xcframework slice
selection is deterministic per destination, so a separate runner
isn't needed, but a separate job gives a focused failure signal
when the device slice breaks while the simulator slice still works.

Side fix: stale comment above the "Build backend bundle" step
referenced ios/nodejs-native/ which Phase 2 doesn't generate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@socket-security

socket-security Bot commented Apr 28, 2026

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedexpo-module-scripts@​55.0.273100100100100
Addedexpo@​55.0.177710078100100
Added@​types/​react@​19.2.141001007988100
Updated@​types/​node@​22.19.15 ⏵ 24.12.2100 +110081 +195 +1100
Addedexeca@​9.6.19910010084100
Addedglobals@​17.5.01001008693100
Addedeslint@​9.39.48910010095100

View full report

gmaclennan and others added 3 commits April 28, 2026 14:04
Android CI runs on Linux runners. The Phase 2 build pipeline tried to
invoke `install_name_tool` and `xcodebuild` unconditionally, which spawn
ENOENT off-darwin and crash before the rollup output gets copied into
Android's resource tree. Wrap the entire iOS framework wrapping pass
in `process.platform === "darwin"`; emit a clear log line on Linux so
"why is ios/Frameworks/ empty after a build" is obvious. Android
instrumented job doesn't consume `ios/Frameworks/`, so this is the
right shape — iOS CI runners are macos-15, they hit the unguarded
path and produce frameworks normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
iOS xcframework migration shipped via PR #16 (`e34505d` on `main`).
Update the family of plans so the open backlog is unambiguous:

- `phase-2-xcframework-plan.md` (iOS, this branch's ancestor):
  Status banner at the top + acceptance items ticked. The version-
  in-the-name (`<name>@<version>.framework`) language proposed in
  earlier drafts is replaced with the unversioned form the branch
  actually shipped — see §0 "Skipped for now". Remaining iOS pre-
  release sanity checks (real-device run, TestFlight upload) listed
  separately. §7 deferred-list cross-references the new Android
  plan.

- `phase-2-android-jnilibs-plan.md` (NEW): mirrors the iOS plan's
  structure for the symmetric Android work. Covers
  `scripts/build-backend.ts` writing to `jniLibs/<abi>/lib<name>.so`,
  `extractNativeLibs="false"` + `useLegacyPackaging=false`, dropping
  the `nodejs-native/<abi>/` overlay extraction in
  `NodeJSService.kt`, and unifying the rollup loader plugin
  (deletes `rollup-plugin-native-paths.js`). Calls out the load-
  bearing constraint inherited from canonical §0.1 — bare-name
  dlopen only — and the diagnostic checks for it. JNI stdio drain
  fix (canonical §0.4 / §8) listed as a separate-PR follow-up:
  same area but orthogonal to packaging.

- `build-architecture-plan.md`: Phase 2 step gains a 2026-04-28
  status banner pointing at the two execution branches and noting
  that the JNI stdio fix is its own PR.

- `unified-js-bundle-ios-plan.md` §7 (Phase 1 review backlog):
  ticks items now satisfied by Phase 2, marks items moot (the
  arch-`#if`, `mergeDirectory` symlink hardening), and points at
  the new Android plan + the Phase 2 plan's §7 as the canonical
  open-backlog index.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two fixes folded into one commit since they share the same surface:

1. Real-device crash: `EACCES: permission denied
   /tmp/comapeo/control.sock` on first launch. iOS apps cannot write
   to the system `/tmp` (an earlier comment claimed otherwise — wrong;
   surfaced on the first real-device run during Phase 2 verification).
   Switch to `targetEnvironment(simulator)`-gated resolution:
   simulator keeps the host Mac's `/tmp` (the only path short enough
   for sockaddr_un.sun_path on simulator); device uses
   `NSTemporaryDirectory()` (the per-app sandbox tmp, ~89 bytes,
   under the 104-byte limit with a 12-byte basename).

2. Multi-instance safety on simulator: two simulators booted on the
   same host Mac would race on `/tmp/comapeo/control.sock`. Append
   the host PID to the namespace dir (`/tmp/comapeo-<pid>/`) so each
   app instance gets a private socket pair. PIDs can be reused after
   the app exits; the existing `deleteSocketFiles()` cleanup at start
   and stop handles same-PID reuse, and the host's standard /tmp
   cleanup eventually collects stragglers from hard crashes — we
   deliberately don't gc stale `/tmp/comapeo-*` dirs ourselves
   because mistakenly deleting a peer simulator's live socket is
   worse than a few KB of orphans. Device path is unchanged
   (per-app sandbox is the namespace).

Side rename: `filesDir` → `socketDir` because the parameter only
ever held socket files. Touches NodeJSService init, MockNodeService
helper, and three test files. Tighter doc comment on
`AppLifecycleDelegate.nodeService` enumerates which iOS storage
locations *don't* fit (Documents, Library/Application Support,
Library/Caches all exceed 104 bytes once a basename is appended)
so the next reader doesn't have to re-derive the constraint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gmaclennan gmaclennan requested a review from Copilot April 28, 2026 14:50
… podspec

Three small post-review cleanups:

1. The iOS rollup loader plugin's regex for `require('bindings')(...)`
   was matching text inside the `betterSqlite3NativeBinding` JSDoc in
   `@comapeo/core`'s patched `mapeo-manager.js`, leaving a nonsense
   `routes around __loadAddon('@comapeo/core')` snippet in the rolled-up
   bundle. Reword the JSDoc to describe the same behaviour without the
   literal pattern; harmless at runtime but confusing for anyone
   reading the bundle while debugging.

2. The simulator `socketDir` doc previously said the host's `/tmp`
   cleanup "eventually" collects stragglers. macOS's
   `com.apple.periodic-daily.plist` actually runs once a day and
   purges entries older than three days; rephrase to that bound so
   the next reader knows what "eventually" actually means.

3. Drop `:tvos` from `ComapeoCore.podspec`. The per-addon xcframeworks
   only ship iOS device + simulator slices, and the wider build
   pipeline assumes the nodejs-mobile xcframework's iOS layout. tvOS
   and visionOS aren't supported targets; advertising tvOS in the
   podspec would surface as a missing-slice error if anyone ever
   tried it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

gmaclennan and others added 5 commits April 28, 2026 16:35
Phase 2's `__loadAddon` was name-keyed only — fine when each native
addon ships at a single version, but the `backend/` dep tree already
carries two versions of two addons:

  * sodium-native: 4.3.3 top-level + 5.1.0 nested under
    sodium-secretstream / noise-handshake / noise-curve-ed /
    @hyperswarm/secret-stream
  * better-sqlite3: 11.10.0 top-level + 12.9.0 under
    styled-map-package-api

Inspection of the rolled-up bundle confirms five distinct callsites
to sodium-native (one to 4.3.3, four to 5.1.0). With name-only keys
all five collapsed to the same `<name>.framework/<name>` and whichever
version dlopen'd first won. The user reported it "has been working by
chance" thanks to NAPI surface stability across the bumps.

Make the loader version-aware end-to-end:

  1. `scripts/build-backend.ts` enumerates every concrete (name,
     version) instance via a new `findNativeModuleInstances` walk of
     the resolved node_modules tree. NATIVE_PAIRS dedupes by composite
     key so four nested copies of sodium-native@5.1.0 share one
     prebuild fetch + one xcframework. Ten xcframeworks emitted today
     (was seven). Android still copies only the top-level version per
     name — Phase 1 single-version behaviour preserved verbatim until
     the Android jniLibs branch lands multi-version on its side.

  2. `<name>__<version>.xcframework` instead of `<name>.xcframework`.
     Underscore-separator (not `@`) because `@` in framework names +
     Mach-O install names is unvalidated; `__` is filesystem-safe and
     accepted by every Apple tool we touch — except CFBundleIdentifier,
     which restricts to [A-Za-z0-9.-], so `buildFrameworkPlist`
     rewrites `__` to `-` in the bundle ID specifically (other plist
     fields keep the underscore).

  3. `rollup-plugin-ios-addon-loader.js` rewrites loader patterns to
     `__loadAddon('name', 'version')` where version comes from the
     transformed file's own containing package.json (so a nested
     `node_modules/.../sodium-native/index.js` rewrite gets 5.1.0,
     while top-level gets 4.3.3). A separate free-form-call rewrite
     catches `globalThis.__loadAddon?.('name')` (currently used in
     `backend/lib/create-comapeo.js` for the better-sqlite3 native
     binding hand-off) and resolves the version via Node's normal
     module resolution from the importer.

  4. Banner `__loadAddon(name, version)` keys the addon cache by
     `name + '__' + version` and dlopens
     `<NATIVE_LIB_DIR>/<name>__<version>.framework/<name>__<version>`.

Local sim run: 4/4 tests still pass. Bundle inspection confirms each
sodium-native callsite gets the version npm's resolution actually
picked for that importer; bare-resolver collapse can't happen.

Known mild bloat: addons with versions that get dead-code-eliminated
by rollup (e.g. better-sqlite3@12.9.0 is reachable only via the maps
fastify plugin, which the iOS bundle stubs to a no-op) still ship as
xcframeworks — wasted ~5MB. Optimization deferred; it'd need post-
rollup analysis to know which (name, version) pairs actually appear
in the bundle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 2 work introduced two paths to load better-sqlite3's native
addon on iOS:

  1. Patch @comapeo/core to accept `betterSqlite3NativeBinding`,
     have `create-comapeo.js` populate it via
     `globalThis.__loadAddon?.('better-sqlite3')`, and thread it
     through to both `new Database(...)` callsites.
  2. Let the rollup plugin's loader-pattern rewrite catch
     `database.js`'s `require('bindings')('better_sqlite3.node')`
     and substitute `__loadAddon('better-sqlite3', '11.10.0')`.

Path 2 is sufficient by itself. The original `bindings` call is the
dead branch when `nativeBinding` is supplied AND when the rewrite
substitutes it — `database.js` gets `addon = __loadAddon(...)` directly,
no filesystem walk, so the underscore-vs-hyphen mismatch
(`better_sqlite3.node` vs. `better-sqlite3` package name) and the
lazy-load timing both stop mattering. Path 1 was belt-and-suspenders.

Drop:
  - `backend/patches/@comapeo+core+7.1.0.patch` (and patches/ dir)
  - `patch-package` devDependency in backend/package.json
  - `npx patch-package` step in scripts/build-backend.ts
  - `betterSqlite3NativeBinding` plumbing in backend/lib/create-comapeo.js
  - free-form `__loadAddon('name')` rewrite + version-resolution
    helper in rollup-plugin-ios-addon-loader.js (no callsites left)
  - `globalThis.__loadAddon = __loadAddon` from the runtime banner
    (no consumer outside the bundle)

The plugin now does one thing: rewrite the three loader patterns to
`__loadAddon(name, version)` where (name, version) come from the
transformed file's containing package. Multi-version dep trees still
resolve correctly per-callsite — verified by inspection of the iOS
bundle, which carries `__loadAddon('sodium-native', '4.3.3')` once
(top-level import path) and `__loadAddon('sodium-native', '5.1.0')`
four times (nested dep paths) exactly as before.

4/4 sim tests pass; bundle is shorter and the moving parts are
fewer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 2's `prepareNodeBundle()` copied `<App>.app/nodejs-project/`
into `Library/Application Support/comapeo-runtime/nodejs-project/` on
every cold start (~24 MB / 50 files) so nodejs-mobile would have a
"writable" filesystem path for the entry. After the Phase 2 simpli-
fications it turns out nothing in the rolled-up backend actually
writes into that tree:

  - Native `.node` files live in `<App>.app/Frameworks/...` and load
    via `process.dlopen` against NATIVE_LIB_DIR (no addon-resolver
    fs walks at runtime — loader patterns are rewritten at build
    time).
  - Drizzle migrations are `fs.readFile`d from
    `node_modules/@comapeo/core/drizzle/*.sql` (read-only access).
  - `@comapeo/default-categories` zip + `@comapeo/fallback-smp`
    blob: read-only.
  - SQLite, blobs, hypercore storage, custom maps: all written under
    `privateStorageDir` (Application Support/comapeo/).

Hand nodejs-mobile the bundle path directly. The `resolveJSEntryPoint`
closure shrinks to one `Bundle.main.bundlePath/nodejs-project/index.mjs`
existence check. The `prepareNodeBundle()` helper, the
`comapeo-runtime` Application Support subdir, and the corresponding
Phase 2.5 follow-up (gate the copy on a CFBundleVersion stamp) all
go away.

iOS-only: Android still extracts on first launch because the APK
doesn't expose a filesystem path for assets the way `<App>.app/`
does on iOS.

4/4 sim tests pass; smoke test specifically confirms drizzle
migrations succeed reading from the bundle path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two adjustments to `readContainingPackage` from a focused review:

1. **Per-directory cache.** Memoize lookups in a Map scoped to the
   plugin closure (one cache per rollup run). Misses are cached too,
   so a file outside any package short-circuits without re-walking.
   The walk now also propagates each ancestor it traverses into the
   cache, so the second file in the same package never re-stat's the
   tree even once. Without the cache the plugin re-walked overlapping
   parent chains for every transformed file in a bundle of thousands.

2. **Stop at `node_modules` ancestor boundary.** Node's module
   resolver treats each `node_modules/<pkg>/` subtree as an
   independent package; if the walk hits a `node_modules` parent
   without finding a valid package.json, the file isn't part of any
   package — walking further would cross into an unrelated parent
   and could mis-attribute the (name, version) pair. Doesn't trigger
   on the current dep tree (every loader-pattern callsite sits inside
   a package whose nearest `package.json` has both fields), but
   future-proofs against a published package shipping an internal
   `package.json` lacking name/version.

4/4 sim tests pass. Multi-version sodium-native still resolves
correctly per-callsite (4.3.3 once, 5.1.0 four times) — confirms the
cache propagates ancestor results without confusing nested packages
that share a parent path with their host.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GitHub's release CDN occasionally serves 5xx responses; one 502 was
enough to fail this PR's `iOS Device Build` CI job while the
concurrent `Integration Tests` job on the same commit succeeded
(same `npm run backend:build`, different runner, no transient blip).
Add `--retry 5 --retry-all-errors --retry-delay 2` so the build
absorbs spurious upstream errors. `--retry-all-errors` is what makes
5xx retryable — by default curl only retries network-level failures,
not protocol-level ones.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@gmaclennan gmaclennan merged commit e50a97e into main Apr 28, 2026
7 checks passed
@gmaclennan gmaclennan deleted the claude/ios-phase-2-xcframework branch April 28, 2026 16:35
gmaclennan added a commit that referenced this pull request Apr 28, 2026
## Summary

Completes Phase 2 of `docs/build-architecture-plan.md` — the symmetric
Android counterpart to PR #16 (iOS xcframework Embed & Sign). Native
`.node` files now ship as `lib<name>__<version>.so` under
`android/src/main/jniLibs/<abi>/`, mmap'd from the APK by Bionic's
per-app linker namespace at load time. The runtime asset extraction step
(`copyAssetFolder("nodejs-native/<abi>", ...)`) and the legacy
`rollup-plugin-native-paths.js` are gone. The iOS-only addon-loader
rollup plugin from #16 is generalised to a single platform-agnostic
plugin with per-platform banners.

Detailed plan:
[`docs/phase-2-android-jnilibs-plan.md`](https://github.com/digidem/comapeo-core-react-native/blob/claude/phase-2-android-jnilibs/docs/phase-2-android-jnilibs-plan.md).
One deviation from the original plan: versioned filenames
(`lib<name>__<version>.so`) are adopted from the start instead of
deferred — the dep tree already carries multi-version `sodium-native`
(4.3.3 + 5.1.0) and `better-sqlite3` (11.10.0 + 12.9.0), surfaced by the
iOS Phase 2 work, so the multi-version path is the reference path.

## Changes

| File | What |
|---|---|
| `scripts/build-backend.ts` | Drops `assets/nodejs-native/<abi>/...`
write; emits `lib<name>__<version>.so` per (name, version) instance ×
ABI into `jniLibs/<abi>/`. Lifts `findNodeForArch` + `androidAbiForArch`
to top-level helpers shared with the iOS xcframework wrap. |
| `backend/rollup-plugins/rollup-plugin-addon-loader.js` (renamed from
`rollup-plugin-ios-addon-loader.js`) | Platform-agnostic loader-pattern
transform. Two banner exports: `iosAddonLoaderBanner`,
`androidAddonLoaderBanner` differ only in the `process.dlopen` argument.
|
| `backend/rollup-plugins/rollup-plugin-native-paths.js` | Deleted. |
| `backend/rollup.config.js` | Both outputs use the unified plugin;
per-platform `output.banner`. |
| `android/build.gradle` | `jniLibs.srcDirs += 'src/main/jniLibs/'`,
`packagingOptions.jniLibs.useLegacyPackaging = false`. |
| `android/src/main/AndroidManifest.xml` | `<application
android:extractNativeLibs="false">`. |
| `android/src/main/java/com/comapeo/core/NodeJSService.kt` | Drops
`nodejs-native` overlay extraction in `start()`; removes unused
`getCurrentABIName` `external fun`, `nodeNativeAssetsDir` field,
`NODEJS_NATIVE_ASSETS_DIRNAME` const. |
| `android/src/main/cpp/jni-bridge.cpp` | Removes `getCurrentABIName`
JNI export + `CURRENT_ABI_NAME` macro (no callers). |
| `.gitignore` | Adds `android/src/main/jniLibs/`. |

## Test plan

Local validation against the running iOS simulator + Android emulator on
this commit:

- [x] **iOS simulator (iPhone 16 / iOS 26.2):** 4/4 tests pass —
`CoreManagerSmokeTest`, `ServiceLifecycleTest`, both
`ComapeoCoreModuleTests`. Confirms the rollup plugin rename didn't
regress iOS.
- [x] **Android JVM unit tests:** `BUILD SUCCESSFUL`.
- [x] **Android instrumented tests (Pixel 7a API 29 emulator,
arm64-v8a):** 16 + 15 = 31 tests, 0 failed. Confirms bare-name
`process.dlopen('lib<key>.so')` works end-to-end against the APK mmap
region with `extractNativeLibs="false"`.
- [x] CI Android workflow on emulator runner *(pending push)*.
- [x] CI iOS workflow simulator + device-build *(pending push)*.

## Out of scope (deferred follow-ups)

- **`NodeJSService.kt` JNI stdio pump drain fix** (canonical
`build-architecture-plan.md` §0.4 / §8). Important for diagnostics —
uncaught backend exceptions are routinely lost to the pthread_detach
race today — but separable from packaging. Recommend its own PR right
after this one.
- iOS pre-release sanity items (real-device runtime test, TestFlight
upload).
- iOS map-tile fetching re-introduction; `globalThis.fetch` polyfill;
maps-stub `console.warn`.
- 16 KB page alignment audit (`readelf -l | grep LOAD` on each shipping
`.so`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gmaclennan added a commit that referenced this pull request Apr 28, 2026
## Summary

Cleanup pass after Phase 2 (PRs #15, #16, #17) landed on `main`.

- **Open follow-up issues** for everything deferred from those PRs:
#19#32 (Android JNI stdio drain, iOS device smoke test, TestFlight
ritual, fetch polyfill, maps plugin re-introduction, Phase 3 smoke test,
Phase 4 socket-transport shim, IPC backpressure, 16KB page alignment
audit, Android lifecycle parity, `abiFilters`, blobs over UDS, web
platform).
- **Remove implemented plan docs** — `docs/Todos.md`,
`docs/phase-2-android-jnilibs-plan.md`,
`docs/phase-2-xcframework-plan.md`,
`docs/unified-js-bundle-ios-plan.md`. `docs/build-architecture-plan.md`
stays (Phase 3-4 still open) with its inline status banners updated to
point at the merged PRs.
- **Trim history-oriented comments** throughout
`scripts/build-backend.ts`, `backend/`, `ios/`, and `android/`: drop
"Phase 1 wrote X / Phase 2 ships Y" framing, references to the external
validating harness in `digidem/nodejs-mobile-bare-prebuilds`, and
lingering `state.sock` naming in favour of `control.sock`.
- **Refresh `agents.md`** to describe the unified rolled-up backend,
jniLibs/xcframework packaging, and the current test layout. Replace the
regression-test history table with a forward-looking "Open follow-ups"
list pointing at the new issues.

No behaviour changes.

## Test plan

- [x] `tsc --noEmit -p scripts/tsconfig.json` clean (only comment edits
there)
- [ ] CI Android workflow green
- [ ] CI iOS workflow green (simulator + device-build)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gmaclennan added a commit that referenced this pull request Jun 22, 2026
## Optic Release Automation

This **draft** PR is opened by Github action
[optic-release-automation-action](https://github.com/nearform-actions/optic-release-automation-action).

A new **draft** GitHub release
[v1.0.0-pre.2](https://github.com/digidem/comapeo-core-react-native/releases/tag/untagged-c499977757c9745e56b2)
has been created.

Release author: @gmaclennan

#### If you want to go ahead with the release, please merge this PR.
When you merge:

- The GitHub release will be published

- The npm package with tag pre will be published according to the
publishing rules you have configured



- No major or minor tags will be updated as configured


#### If you close the PR

- The new draft release will be deleted and nothing will change

## What's Changed
* Android Testing Infrastructure & Bug Fixes by @gmaclennan in
#3
* chore: prebuild example/android; harden instrumented tests by
@gmaclennan in
#10
* Integrate @comapeo/core via IPC over Unix sockets by @gmaclennan in
#5
* chore: adjust repo setup by @achou11 in
#12
* chore: minor fixes based on expo-doctor by @achou11 in
#13
* Add iOS support & test infrastructure by @gmaclennan in
#6
* chore: add architecture docs & plans by @gmaclennan in
#11
* update some native deps used in backend by @achou11 in
#14
* iOS Phase 1: unified JS bundle + smoke test (simulator-only) by
@gmaclennan in
#15
* iOS Phase 2: xcframework Embed & Sign for native addons by @gmaclennan
in #16
* Phase 2 Android: jniLibs packaging + unified rollup loader plugin by
@gmaclennan in
#17
* chore: post-Phase-2 cleanup — comments, plan docs, agents.md by
@gmaclennan in
#33
* android: read abiFilters from reactNativeArchitectures (#30) by
@gmaclennan in
#35
* refactor: simplify build-backend.ts; rollup writes directly to native
asset trees by @gmaclennan in
#34
* chore: fix eslint configuration by @achou11 in
#41
* android: audit 16 KB page alignment on every shipped .so by
@gmaclennan in
#43
* Add rootkey persistence and lifecycle state management by @gmaclennan
in #36
* chore: move example app into apps directory by @achou11 in
#18
* refactor: per-component lifecycle state with derived ComapeoState by
@gmaclennan in
#47
* android: fold waitForFile into connect retry loop by @gmaclennan in
#52
* chore: add e2e testing app by @achou11 in
#49
* fix(android): drop setUnlockedDeviceRequired from rootkey wrapper key
by @gmaclennan in
#57
* fix(backend): cache stopping/error frames for late joiners by
@gmaclennan in
#58
* fix(ios-tests): wait for STOPPING before signalling node exit by
@gmaclennan in
#59
* fix(android): drain JNI stdio pumps before returning from node::Start
by @gmaclennan in
#60
* Sentry integration: Phase 1 + Phase 2a + Phase 2b by @gmaclennan in
#54
* feat(backend): polywasm-backed undici on iOS, re-enable maps plugin by
@gmaclennan in
#62
* ci: drop unreliable Android emulator snapshot caching by @gmaclennan
in #64
* feat(sentry): land Phase 3 — backend loader + RPC tracing by
@gmaclennan in
#63
* fix(ios-tests): serialise STOPPING/STOPPED observers in
testFullLifecycleStateTransitions by @gmaclennan in
#71
* use npm list instead of custom traversal to get native module versions
by @achou11 in
#70
* feat(sentry): land Phases 6 + 7a — Android exit reasons & iOS
MetricKit app-exit telemetry by @gmaclennan in
#72
* fix(sentry): make exit telemetry lossless and stop cross-process
clobbering by @gmaclennan in
#84
* chore(e2e): add e2e tests on browserstack via Maestro by @achou11 in
#56
* feat(sentry): migrate to @sentry/react-native v8; exit telemetry as
Application Metrics by @gmaclennan in
#73
* Map server integration by @gmaclennan in
#86
* chore(deps): upgrade to Expo SDK 56 (React Native 0.85) by @gmaclennan
in #87
* chore(ci): add release workflow by @gmaclennan in
#90
* chore: fix npm script and release build script by @gmaclennan in
#91
* chore(pack): don't try to package build files by @gmaclennan in
#92
* fix: start fastify listening by @gmaclennan in
#93
* perf(backend): switch bundler from rollup to rolldown by @gmaclennan
in #94
* fix(ci): ignore-scripts in ios npm installs by @gmaclennan in
#96
* fix(ci): replace --ignore-scripts with npm strict-allow-scripts
allowlist by @gmaclennan in
#106
* feat(config): let the consuming app supply the default project config
by @gmaclennan in
#95
* chore(release): merge prerelease branch. by @gmaclennan in
#110

## New Contributors
* @achou11 made their first contribution in
#12

**Full Changelog**:
https://github.com/digidem/comapeo-core-react-native/commits/v1.0.0-pre.2

<!--

<release-meta>{"id":342868678,"version":"v1.0.0-pre.2","npmTag":"pre","opticUrl":"https://optic-zf3votdk5a-ew.a.run.app/api/generate/"}</release-meta>
-->
@gmaclennan gmaclennan added the feature New feature (changelog) label Jun 22, 2026
gmaclennan added a commit that referenced this pull request Jun 22, 2026
## Optic Release Automation

This **draft** PR is opened by Github action
[optic-release-automation-action](https://github.com/nearform-actions/optic-release-automation-action).

A new **draft** GitHub release
[v1.0.0-pre.2](https://github.com/digidem/comapeo-core-react-native/releases/tag/untagged-352a6c41c12fd02dec37)
has been created.

Release author: @gmaclennan

#### If you want to go ahead with the release, please merge this PR.
When you merge:

- The GitHub release will be published

- The npm package with tag pre will be published according to the
publishing rules you have configured



- No major or minor tags will be updated as configured


#### If you close the PR

- The new draft release will be deleted and nothing will change

<!-- Release notes generated using configuration in .github/release.yml
at 7fe80b4 -->

## What's Changed
### 🚀 Features
* Integrate @comapeo/core via IPC over Unix sockets by @gmaclennan in
#5
* Add iOS support & test infrastructure by @gmaclennan in
#6
* iOS Phase 1: unified JS bundle + smoke test (simulator-only) by
@gmaclennan in
#15
* iOS Phase 2: xcframework Embed & Sign for native addons by @gmaclennan
in #16
* Phase 2 Android: jniLibs packaging + unified rollup loader plugin by
@gmaclennan in
#17
* android: read abiFilters from reactNativeArchitectures (#30) by
@gmaclennan in
#35
* Add rootkey persistence and lifecycle state management by @gmaclennan
in #36
* Sentry integration: Phase 1 + Phase 2a + Phase 2b by @gmaclennan in
#54
* feat(backend): polywasm-backed undici on iOS, re-enable maps plugin by
@gmaclennan in
#62
* feat(sentry): land Phase 3 — backend loader + RPC tracing by
@gmaclennan in
#63
* feat(sentry): land Phases 6 + 7a — Android exit reasons & iOS
MetricKit app-exit telemetry by @gmaclennan in
#72
* feat(sentry): migrate to @sentry/react-native v8; exit telemetry as
Application Metrics by @gmaclennan in
#73
* Map server integration by @gmaclennan in
#86
* feat(config): let the consuming app supply the default project config
by @gmaclennan in
#95
### 🐛 Bug Fixes
* fix(android): drop setUnlockedDeviceRequired from rootkey wrapper key
by @gmaclennan in
#57
* fix(backend): cache stopping/error frames for late joiners by
@gmaclennan in
#58
* fix(ios-tests): wait for STOPPING before signalling node exit by
@gmaclennan in
#59
* fix(android): drain JNI stdio pumps before returning from node::Start
by @gmaclennan in
#60
* fix(ios-tests): serialise STOPPING/STOPPED observers in
testFullLifecycleStateTransitions by @gmaclennan in
#71
* fix(sentry): make exit telemetry lossless and stop cross-process
clobbering by @gmaclennan in
#84
* fix: start fastify listening by @gmaclennan in
#93
* fix(ci): ignore-scripts in ios npm installs by @gmaclennan in
#96
* fix(ci): replace --ignore-scripts with npm strict-allow-scripts
allowlist by @gmaclennan in
#106
* fix(release): stop `npm pack --dry-run` leaking dry-run into backend
install by @gmaclennan in
#129
### ⚡ Performance
* perf(backend): switch bundler from rollup to rolldown by @gmaclennan
in #94
### ⬆️ Dependencies
* update some native deps used in backend by @achou11 in
#14
* chore(deps): upgrade to Expo SDK 56 (React Native 0.85) by @gmaclennan
in #87
### 🏗️ Maintenance
* Android Testing Infrastructure & Bug Fixes by @gmaclennan in
#3
* chore: prebuild example/android; harden instrumented tests by
@gmaclennan in
#10
* chore: adjust repo setup by @achou11 in
#12
* chore: minor fixes based on expo-doctor by @achou11 in
#13
* chore: add architecture docs & plans by @gmaclennan in
#11
* chore: post-Phase-2 cleanup — comments, plan docs, agents.md by
@gmaclennan in
#33
* refactor: simplify build-backend.ts; rollup writes directly to native
asset trees by @gmaclennan in
#34
* chore: fix eslint configuration by @achou11 in
#41
* android: audit 16 KB page alignment on every shipped .so by
@gmaclennan in
#43
* chore: move example app into apps directory by @achou11 in
#18
* refactor: per-component lifecycle state with derived ComapeoState by
@gmaclennan in
#47
* android: fold waitForFile into connect retry loop by @gmaclennan in
#52
* chore: add e2e testing app by @achou11 in
#49
* ci: drop unreliable Android emulator snapshot caching by @gmaclennan
in #64
* use npm list instead of custom traversal to get native module versions
by @achou11 in
#70
* chore(e2e): add e2e tests on browserstack via Maestro by @achou11 in
#56
* chore(ci): add release workflow by @gmaclennan in
#90
* chore: fix npm script and release build script by @gmaclennan in
#91
* chore(pack): don't try to package build files by @gmaclennan in
#92
* chore(release): merge prerelease branch. by @gmaclennan in
#110
* ci(e2e): retry BrowserStack builds on infra-class flakes by
@gmaclennan in
#113
### Other Changes
* ci: derive changelog labels from PR titles + add Dependabot by
@gmaclennan in
#114

## New Contributors
* @achou11 made their first contribution in
#12
* @optic-release-automation[bot] made their first contribution in
#112

**Full Changelog**:
https://github.com/digidem/comapeo-core-react-native/commits/v1.0.0-pre.2

<!--

<release-meta>{"id":342970724,"version":"v1.0.0-pre.2","npmTag":"pre","opticUrl":"https://optic-zf3votdk5a-ew.a.run.app/api/generate/"}</release-meta>
-->
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature (changelog)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants