iOS Phase 2: xcframework Embed & Sign for native addons#16
Merged
Conversation
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>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
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>
… 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>
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>
5 tasks
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>
This was referenced Apr 28, 2026
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
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> -->
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
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 2 of
docs/build-architecture-plan.md, narrowed to iOS only — AndroidjniLibs/migration is a sibling PR. Replaces Phase 1's runtime asset overlay (AppLifecycleDelegate.prepareNodeBundle()mergingnodejs-native/<arch>/overnodejs-project/) with one<name>.xcframeworkper native addon, embedded by Xcode's standard Embed & Sign phase. Native.nodefiles now load viaprocess.dlopenagainst<App>.app/Frameworks/— zero runtime extraction, codesigned for free, device + simulator both supported.Six commits, one logical step each (review them in order):
docs/phase-2-xcframework-plan.md) — scope decisions, recipe, risks, acceptance criteria.scripts/build-backend.ts— emitios/Frameworks/<name>.xcframeworkper addon (device arm64 + lipo'd simulator). Dropsios/nodejs-native/. Recipe lifted fromdigidem/nodejs-mobile-bare-prebuilds@feat/jnilibs-xcframework-packaging's validated harness, plusinstall_name_tool -id @rpath/...(which the harness skipped — see commit message).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 viaoutput.banner. Android keeps the existingrollup-plugin-native-paths.backend/patches/@comapeo+core+7.1.0.patch+ create-comapeo wiring — patch-package patch addingbetterSqlite3NativeBindingoption toMapeoManager/MapeoProject. better-sqlite3 already supportsnativeBinding; 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 ownnativeBindingoption for discoverability.ComapeoCore.podspecswitches tos.vendored_frameworks = ['NodeMobile.xcframework', 'Frameworks/*.xcframework']+ dropsnodejs-nativefroms.resources.AppLifecycleDelegateexportsNATIVE_LIB_DIR=$bundlePath/FrameworksbeforeNodeMobileStartNode, andprepareNodeBundle()collapses to a JS-only copy (no overlay, no arch picker —mergeDirectory()deleted).device-buildjob runsxcodebuild build -sdk iphoneoswithCODE_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:buildproducesios/Frameworks/<name>.xcframeworkfor all 7 native modules (device + fat simulator slices).otool -Don each framework binary returns@rpath/<name>.framework/<name>.xcodebuild teston iPhone 16 / iOS 26.2: 4/4 integration tests pass —CoreManagerSmokeTest,ServiceLifecycleTest, bothComapeoCoreModuleTests. better-sqlite3 loads via the patchednativeBindingpath; sodium-native + the other holepunch modules load via__loadAddonagainstNATIVE_LIB_DIR.Out of scope
jniLibs/migration (sibling PR).NodeJSService.ktJNI stdio pump drain fix (canonical §0.4).prepareNodeBundle()cold-start copy (Phase 2.5).globalThis.fetchpolyfill, maps-stubconsole.warn(tracked inunified-js-bundle-ios-plan.md§7).🤖 Generated with Claude Code