Embeds CoMapeo Core in a React Native app. The core runs inside a bundled Node.js runtime (nodejs-mobile) in a background process; this package exposes an RPC client to talk to it, lifecycle and permission helpers, an offline map server, and an Expo config plugin that wires up the native build.
The package ships native code, so it does not run in Expo Go — you need a development build (or a bare project with the native changes applied).
- Expo SDK 56 (the package is built and tested against it).
@sentry/react-nativeis a peer dependency and must be installed. Sentry stays inert at runtime unless you configure it (see Sentry).- Node 24 to build the package from source.
npx expo install @comapeo/core-react-native @sentry/react-native
Add the config plugin in app.json / app.config.js:
{
"expo": {
"plugins": ["@comapeo/core-react-native"]
}
}Then create a development build:
npx expo prebuild
npx expo run:android # or: npx expo run:iosThe plugin runs during prebuild and applies the required native config (see
Config plugin). Pass options as the second element of the
plugin array.
First install and configure expo
modules, then:
npm install @comapeo/core-react-native @sentry/react-native
npx pod-install # iOS
The config plugin only runs under expo prebuild. If you don't use prebuild,
apply its native changes by hand:
- Allow cleartext HTTP to loopback only — an Android
network-security-configscoped to127.0.0.1/localhost, and the iOSNSAllowsLocalNetworkingATS key. The map server runs as plain HTTP on loopback, which release builds otherwise block. - Add the Sentry library-evolution Podfile hook
(
BUILD_LIBRARY_FOR_DISTRIBUTION = YESforSentry*pods) inside yourpost_installblock. - See Android backup rules for a manifest-merger conflict you may hit.
The native module starts and supervises the embedded backend on its own — an
Android foreground service, and the app lifecycle on iOS. You observe it through
state and talk to it through comapeo once it has started.
import { state } from "@comapeo/core-react-native";
state.getState(); // "STOPPED" | "STARTING" | "STARTED" | "STOPPING" | "ERROR"
const sub = state.addListener("stateChange", (next, error) => {
if (next === "STARTED") {
// RPC is safe to use.
}
if (next === "ERROR" && error) {
console.warn(error.errorPhase, error.errorMessage);
}
});
// sub.remove() when donegetState()— current lifecycle state.getLastError()— structured detail from the most recentERROR, ornull."stateChange"— fires on every transition; the second argument carries{ errorPhase, errorMessage }onERROR, otherwisenull."messageerror"— fires (with anError) when the backend sends a control frame the native side can't parse. It does not change the lifecycle state; useful for debugging only.
On ERROR the native layer leaves the backend process in place — recovery is
up to the app (restart the service, prompt the user, log a report).
comapeo is the @comapeo/ipc
client for CoMapeo Core (the MapeoManager API: projects, observations, sync,
etc.).
You don't have to wait for STARTED to call it. Calls made before the backend
is ready are buffered and resolve once it starts. Every call has a 30s timeout,
so a call that gets no answer — the backend failed to boot, hit ERROR, or the
process isn't running — rejects rather than hanging (in-flight calls may reject
sooner when the transport closes). On ERROR the backend is not restarted
automatically; observe state and recover as appropriate.
import { comapeo } from "@comapeo/core-react-native";
const projectId = await comapeo.createProject({ name: "My project" });
const project = await comapeo.getProject(projectId);See the CoMapeo Core / @comapeo/ipc documentation for the full method surface.
RPC client for services the app provides to the backend. Today its only member is the map server:
import { comapeoServicesClient } from "@comapeo/core-react-native";
const baseUrl = await comapeoServicesClient.mapServer.getBaseUrl();
// → http://127.0.0.1:<port>The foreground service posts an ongoing notification. On Android 13+ (API 33)
posting it needs the runtime POST_NOTIFICATIONS grant; without it the system
suppresses the notification and may deprioritise the service. The module
declares the permission and exposes check/request helpers so you don't have to
add expo-notifications for this alone:
import {
getNotificationPermissionsAsync,
requestNotificationPermissionsAsync,
} from "@comapeo/core-react-native";
const current = await getNotificationPermissionsAsync();
if (!current.granted && current.canAskAgain) {
await requestNotificationPermissionsAsync();
}Both resolve an expo-style PermissionResponse
({ status, granted, canAskAgain, expires }), interchangeable with
expo-camera, expo-location, etc. On Android < 13 and on iOS they resolve as
granted without a dialog, so you can call them unconditionally.
The module never prompts on its own — you decide when to ask and own the
rationale and the "open settings" fallback once canAskAgain is false.
Starting the service does not require the grant: if it's missing, the service
still starts (without a visible notification). If you already request
POST_NOTIFICATIONS via expo-notifications, you don't need these helpers. See
docs/ForegroundService.md for the rationale.
The backend runs an offline-capable map server
(@comapeo/map-server)
over loopback HTTP. Point a renderer such as MapLibre
at the local URL to draw background maps, including offline.
const baseUrl = await comapeoServicesClient.mapServer.getBaseUrl();
const styleUrl = `${baseUrl}/maps/fallback/style.json`;Three map IDs are served under /maps/<id>/…:
fallback— a small offline map bundled with the module (@comapeo/fallback-smp); always available.default— redirects to the configured online style (seedefaultOnlineStyleUrl), so it needs a network connection.custom— an offline.smpimported through the app; returns 404 until one is added.
Register the plugin and pass options as the second array element:
// app.config.js
export default {
expo: {
plugins: [
["@comapeo/core-react-native", {
defaultConfig: "./assets/categories.comapeocat",
defaultOnlineStyleUrl: "https://example.com/style.json",
}],
],
},
};Options are baked in at prebuild, so changing any of them requires a new
prebuild and build. Regardless of options, the plugin always adds the
loopback-only cleartext exception (so the map server is reachable), the iOS
local-network usage description (so peer sync works — see
localNetworkPermission), and, on iOS, the Sentry
library-evolution Podfile hook.
Path to a .comapeocat file bundled into the app and applied to every project
created without an explicit config. The path is resolved relative to your
project root. Omit it and new projects start with no presets/categories. Use
@comapeo/default-categories
(or your own build) as the source — this module does not ship one.
If you set defaultConfig and later remove it, run a clean prebuild
(expo prebuild --clean) so the stale file is dropped from the iOS project; a
non-clean prebuild leaves the reference behind.
The online map style used as a fallback when no offline map is available. Must
be an http(s) URL. Defaults to MapLibre's demo tiles
(https://demotiles.maplibre.org/style.json).
The iOS Local Network usage description shown when the app first connects to peers on the local network for sync. Defaults to a generic string. Override it to localise or reword the prompt:
["@comapeo/core-react-native", {
localNetworkPermission: "MyApp connects to nearby devices to sync your data.",
}];The plugin owns the NSLocalNetworkUsageDescription key, so set the wording here
rather than in your own Info.plist or it will be overwritten. mDNS/Bonjour
discovery stays your app's responsibility: this module currently neither browses nor
advertises services, so if your app does, add the matching NSBonjourServices
entries yourself. Android needs nothing — its cleartext/network-security config
doesn't gate the Node thread's sockets, and there's no equivalent permission.
Opt into Sentry by passing a sentry object (see Sentry for the
full integration). All values are written into AndroidManifest meta-data and
Info.plist keys at prebuild.
| Key | Required | Description |
|---|---|---|
dsn |
yes | Sentry DSN. Source from process.env so EAS profiles produce different builds (requires app.config.js, not app.json). |
environment |
yes | Sentry environment (e.g. production, staging). |
release |
no | Release tag. Defaults to the app's version (versionName+versionCode / CFBundleShortVersionString+CFBundleVersion). |
sampleRate |
no | Error sample rate (0–1). |
tracesSampleRate |
no | Performance trace sample rate. Default 0.1 when capture-application-data is on; 0 when off. |
rpcArgsBytes |
no | Max bytes of RPC arguments captured on spans. |
diagnosticsEnabledDefault |
no | Fresh-install default for the diagnostics toggle. |
captureApplicationDataDefault |
no | Fresh-install default for the capture-application-data toggle. Keep off in production. |
enableLogs |
no | Forward Sentry structured logs from the backend process. Pair with enableLogs: true in your host Sentry.init setup. |
Omitting sentry (or removing it on a re-prebuild) strips all keys this plugin
owns, leaving any keys other plugins wrote in place.
Optional. The module can forward its native and JS lifecycle events into the
host app's @sentry/react-native. It owns the RN-side Sentry.init call, so
the host wires Sentry through this module rather than initialising it directly.
- Configure the plugin's
sentryoption with at leastdsnandenvironment(seesentryoptions). - Initialise once at app entry — do not call
Sentry.inityourself:
import { initSentry } from "@comapeo/core-react-native/sentry";
import * as Sentry from "@sentry/react-native";
initSentry({
integrations: (defaults) => [...defaults, Sentry.reactNavigationIntegration()],
beforeSend: (event) => event, // runs after the module's scrubber
tags: { releaseChannel: "internal" },
});initSentry reads the plugin-baked DSN, environment, release, and sample rates
and wires the RN, Node, and Android-FGS sides to the same values. Locked options
(dsn, release, environment, sampleRate, tracesSampleRate,
sendDefaultPii: false, enableLogs, user.id) come from the plugin and can't
be overridden — TypeScript rejects them at the call site. It throws if the host
called Sentry.init separately, and is a no-op if diagnostics are disabled or
no DSN was baked in.
From @comapeo/core-react-native/sentry:
initSentry(options?)— initialise Sentry. Call once.sentryConfig— read-only view of the plugin-baked options (empty{}when the plugin isn't configured). For inspection only; don't spread it into a separateSentry.init.getDiagnosticsEnabled()/setDiagnosticsEnabled(value)— the diagnostics opt-out toggle. Restart-to-activate; settingfalsealso wipes the on-disk envelope cache.getCaptureApplicationData()/setCaptureApplicationData(value)— the capture-application-data toggle (gates traces and richer payloads).
For readable stack traces, Sentry needs the symbolication artifacts that match
each release. This module captures errors from three runtimes (the Node
backend, the React Native JS layer, and native code — including the Android
:ComapeoCore foreground-service process), and they don't all symbolicate from
the same artifact. Split the upload responsibility this way:
| Artifact | Covers | Who uploads it |
|---|---|---|
| Node backend sourcemaps | The embedded Node.js bundle this module ships (minified) | This module's comapeo-rn-upload-sourcemaps CLI (below) |
| JS bundle sourcemaps | Your app's React Native JS (Hermes) | Your standard @sentry/react-native setup |
| iOS dSYMs | Native crashes (your code, sentry-cocoa, the in-process Node thread) |
Your standard @sentry/react-native setup |
| Android ProGuard/R8 mapping + NDK debug symbols | Native crashes in both the main and :ComapeoCore processes, minified Kotlin/Java |
Your standard @sentry/react-native setup |
This module owns the RN-side Sentry.init call (via initSentry),
but it does not take over uploading your app's own JS/native artifacts.
Because initSentry runs with autoInitializeNativeSdk: false and the module
enables the native + FGS crash reporters, you still need the standard upload
pipeline below or native crashes and JS errors arrive unsymbolicated.
The Node backend ships minified, so upload its sourcemaps with the bundled CLI. The sourcemaps carry content-hashed Sentry debug IDs, so you don't need to align this module's version with your app's release:
SENTRY_AUTH_TOKEN=… npx comapeo-rn-upload-sourcemaps \
--org your-org --project your-projectRun it from your release CI (after eas build, or wherever you build the app).
Re-uploading is idempotent (Sentry de-dupes by debug ID). The CLI finds
@sentry/cli via @sentry/react-native's dependency chain — if you don't have
@sentry/react-native installed, add @sentry/cli to your devDependencies.
--targets <list> restricts the upload to a subset of
android-debug, android-main, ios; --url points at self-hosted Sentry;
SENTRY_ORG / SENTRY_PROJECT work in place of the flags.
The backend sourcemaps live in sibling nodejs-sourcemaps/ directories (not
under the bundled nodejs-project/ assets), so they are not shipped inside
your APK/IPA — no exclusion step is needed to keep them off the device.
These are uploaded by the standard @sentry/react-native tooling, exactly as
for any Sentry-enabled Expo/React Native app — this module changes nothing about
that pipeline. The most common path on Expo is to add the
@sentry/react-native/expo
config plugin alongside this one, which wires up JS sourcemap and native
debug-symbol upload during eas build:
// app.config.js
export default {
expo: {
plugins: [
["@comapeo/core-react-native", { sentry: { /* … */ } }],
["@sentry/react-native/expo", {
organization: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
}],
],
},
};Adding the Sentry Expo plugin for artifact upload does not conflict with
this module owning Sentry.init — the plugin only configures the build-time
upload and Metro, it does not call Sentry.init. Still initialise Sentry
through initSentry, not Sentry.init directly.
Provide SENTRY_AUTH_TOKEN (with project:releases and org:read scope) to
the build so the upload can run. For a bare React Native project, follow
Sentry's manual source-maps
and dSYM/NDK docs
instead of the Expo plugin.
Keep your app's Sentry release aligned with what this module's plugin bakes
in — by default versionName + "+" + versionCode (Android) /
CFBundleShortVersionString + "+" + CFBundleVersion (iOS), or whatever you pass
as the plugin's release option — so every layer's events resolve against the
same release in Sentry.
See docs/sentry-integration-plan.md for
the design and docs/ARCHITECTURE.md §7 for the
overview.
The module's AndroidManifest.xml sets android:dataExtractionRules and
android:fullBackupContent to exclude the rootkey-bearing SharedPreferences
from cloud backup and device-to-device transfer. The rootkey is wrapped by a
device-bound AndroidKeyStore key, so a backed-up copy is useless on another
device; excluding it avoids a confusing restore-then-fail flow.
If your app already declares either attribute, the manifest merger fails with a "different value declared" error. To resolve it:
- Merge the module's exclusions into your own rules XML — add
<exclude domain="sharedpref" path="comapeo-core.xml" />under both<cloud-backup>and<device-transfer>in yourdataExtractionRules, and the same under<full-backup-content>in yourfullBackupContent. The module's defaults are inandroid/src/main/res/xml/for reference. - Add
tools:replace="android:dataExtractionRules,android:fullBackupContent"to your app's<application>tag.
See CONTRIBUTING.md for development setup, tests, and commit/PR/release conventions, and AGENTS.md for the architecture and a directory-by-directory breakdown.