Skip to content

feat(react-native): Expo module for on-device Moss (#432) - #473

Open
samanyugoyal2010 wants to merge 10 commits into
usemoss:mainfrom
samanyugoyal2010:issue-432-react-native-expo
Open

feat(react-native): Expo module for on-device Moss (#432)#473
samanyugoyal2010 wants to merge 10 commits into
usemoss:mainfrom
samanyugoyal2010:issue-432-react-native-expo

Conversation

@samanyugoyal2010

@samanyugoyal2010 samanyugoyal2010 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Adds @moss-dev/moss-react-native under sdks/react-native/ — an Expo Modules package that exposes a MossClient SharedObject for on-device semantic search.
  • iOS: links Moss.xcframework (same binary as the Swift SDK, v0.6.2), downloaded + checksum-verified at pod install.
  • Android: clear stub that throws until native Android builds land (#411).
  • Includes an Expo config plugin, package README, and examples/react-native/ usage sketch. Updates ROADMAP / AGENTS / root README.

Closes #432

Notes

  • Requires a development build / expo prebuild (not Expo Go).
  • First release covers the cloud + local query loop (createIndex, loadIndex, query, addDocs, index CRUD). Session / Authenticator APIs from the Swift SDK are deferred.

Test plan

  • cd sdks/react-native && npm install && npm run build
  • Wire the package into an Expo app with plugins: ["@moss-dev/moss-react-native"], run npx expo prebuild + pod install, confirm Moss.xcframework downloads
  • On iOS simulator/device: create index → load → query with portal credentials
  • On Android: confirm constructing MossClient throws with the SDK: Kotlin / Android bindings #411 message
  • Confirm Expo Go is documented as unsupported

Made with Cursor

Review in cubic

Introduce @moss-dev/moss-react-native under sdks/react-native with an
Expo Modules SharedObject wrapping Moss.xcframework on iOS, an Android
stub pointing at usemoss#411, a config plugin, and a usage example.

Co-authored-by: Cursor <cursoragent@cursor.com>
Raise the iOS deployment target to 16.4, wire MossC module/search paths and
system frameworks, and exclude x86_64 simulator so prebuild + link succeed
on Apple Silicon.

Co-authored-by: Cursor <cursoragent@cursor.com>
@samanyugoyal2010

Copy link
Copy Markdown
Contributor Author

@cubic.dev

@cubic-dev-ai

cubic-dev-ai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

@cubic.dev

@samanyugoyal2010 I can't start this review because you've reached your trial's review limit. Trial plans have lower review limits than paid plans. Upgrade now to resume reviews.

To help optimise your usage, you can tune cubic to get the most out of your usage limits:

Learn more →

@github-actions

github-actions Bot commented Jul 25, 2026

Copy link
Copy Markdown

Codex review

No issues found.

samanyugoyal2010 and others added 3 commits July 30, 2026 23:35
Three fixes from review on usemoss#473:

- MossClientSharedObject: borrow() returned the raw handle after dropping
  the lock, so a JS `client.query(...); client.close();` could free the
  handle while an async native call was still using it. Every operation is
  an AsyncFunction running off the JS thread while close() is a synchronous
  Function on it, so the race was reachable. Replace borrow() with
  withHandle(), which refcounts in-flight calls under an NSCondition;
  close() now marks the client closed, drains outstanding calls, and only
  then calls moss_client_free. Concurrent operations still run in parallel
  — only teardown serializes against them.

- app.plugin.js: the plugin only set ios.deploymentTarget when it was
  missing, so an app pinning a lower value (e.g. 15.1) kept a target that
  CocoaPods rejects against the podspec's 16.4. Parse the existing value
  and raise it whenever it is lower, unset, or unparseable. Comparison is
  numeric per component, so 16.10 is correctly treated as newer than 16.4.

- parseIndexInfo: nullable C strings were boxed with `as Any`, putting
  Optional.none into the dictionary handed to the bridge. Build the
  dictionary conditionally instead, matching the existing style in
  parseSearchResult; the omitted keys are already optional in IndexInfo
  and ModelRef.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two more fixes from review on usemoss#473:

- Numeric options were converted straight from the bridged Double, so
  `UInt64((options["pollingIntervalSeconds"] as? Double) ?? 600)` trapped on
  NaN, ±Infinity, negatives and oversized JS numbers — an uncatchable
  Swift runtime trap that kills the app process before any error can be
  bridged back. Reproduced locally: "Fatal error: Double value cannot be
  converted to UInt64 because it is either infinite or NaN". topK and alpha
  had the same shape. Added integerOption / unitIntervalOption, which check
  isFinite and explicit bounds and throw a MossError for invalid values
  before converting. Bounds stay well inside 2^53 so the Double comparisons
  are exact. Accepts Int/NSNumber as well as Double, since the bridge does
  not guarantee which it hands over.

- query() hard-coded `embedding: nil`, so an index created from custom
  document embeddings — which the JS wrapper auto-selects by setting
  modelId 'custom' whenever a doc carries an embedding — could be built but
  never queried with a query vector. Added `embedding?: number[]` to
  QueryOptions, marshalled into MossQueryOptions via withUnsafeBufferPointer
  (same shape as MossSession.query in the Swift SDK) so the buffer stays
  alive across the call. Non-finite components are rejected up front.
  Documented the pairing in the README and on MossClient.query.

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

Two more findings from review on usemoss#473:

- QueryOptions only accepted `filterJson`, so a caller migrating from
  `@moss-dev/moss` — which takes `filter?: Record<string, unknown>` — would
  have their filter silently dropped and get an unfiltered query returning
  documents outside the intended scope. Added `filter` with the same shape
  as the Node SDK, serialized to the engine's JSON form. Passing both
  `filter` and `filterJson` is an error rather than a silent precedence
  rule, and an unserializable filter raises a MossError instead of falling
  back to null.

- The quick start put the project key in `EXPO_PUBLIC_MOSS_PROJECT_KEY`.
  Expo inlines `EXPO_PUBLIC_*` into the shipped JS bundle, so that key is
  readable by anyone with the app, and MossClient also exposes createIndex /
  addDocs / deleteIndex — a leaked key is not just read access. Documented
  this in a Credentials section, marked the quick start and the example as
  development-only, and pointed at the Authenticator path
  (moss_client_new_with_authenticator) as the intended fix.

Note: the Authenticator/session bridge itself is NOT implemented here —
Session / Authenticator APIs are deferred for this first release, so an
embedded project key must be treated as public until it lands.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread sdks/react-native/src/MossClient.ts Outdated
Comment thread sdks/react-native/README.md
@samanyugoyal2010

Copy link
Copy Markdown
Contributor Author

@HarshaNalluru you can review and merge

…narrowing

Two findings from review on usemoss#473, plus a same-class sweep:

- MossReactNative.podspec relied on `prepare_command` to fetch
  Moss.xcframework. CocoaPods only runs that for pods it downloads into
  Pods/ — Expo/RN autolinking installs this one from node_modules as a
  development pod (`:path`), which skips it. Combined with .npmignore
  excluding ios/Frameworks/, `vendored_frameworks` pointed at a directory
  that never existed. The download now runs while the podspec is evaluated,
  which happens before CocoaPods resolves vendored_frameworks and on every
  install path. Verified end to end: evaluating the podspec fetches and
  checksum-verifies the binary, and a second run is a no-op.

- embeddingOption checked `isFinite` on the Double before narrowing, but a
  finite Double above Float.greatestFiniteMagnitude (~3.4e38) rounds to
  Float.infinity, so oversized query embeddings still reached the engine as
  non-finite. Now narrows first and checks the Float. 1e300 is rejected,
  3.4e38 still accepted.

Same-class sweep (not flagged, same defect shape as findings already
raised): createIndex/addDocs passed docs straight to JSON.stringify, which
silently rewrites NaN/±Infinity to null — a corrupted embedding reaching
the engine rather than an error. Added serializeDocs, which rejects
non-finite and out-of-Float-range embedding values and reports an
unserializable doc set as a MossError instead of a raw TypeError. Bounds
match the native check exactly.

MossClientSharedObject.swift now type-checks against the real MossC header
from the xcframework (ExpoModulesCore/UIKit stubbed), confirming the
MossQueryOptions field order and the embedding marshalling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread sdks/react-native/ios/MossReactNative.podspec Outdated
Comment thread sdks/react-native/ios/MossClientSharedObject.swift Outdated
…Json

Two findings from review on usemoss#473:

- close() waited for in-flight calls before freeing the handle. It is
  exposed as a synchronous JS `Function`, so that wait ran on the JS thread
  and could freeze the UI for as long as a slow loadIndex / query / mutation
  took — component cleanup and error paths being the obvious cases. Kept the
  use-after-free protection but handed ownership of the free to whoever is
  last out: close() marks the client closed and frees only if nothing is in
  flight, otherwise the final release() does it. acquire() still refuses once
  closed, so no new work can start either way. NSCondition is now an NSLock
  since nothing waits.

  Verified with a 400-trial concurrency simulation of the exact logic
  (8 workers racing close()): 400 frees for 400 trials, so no double-free
  and no leak, zero use-after-free, and close() returns in under 2µs.

- resolveFilterJson returned `options?.filterJson ?? null` unchecked. A
  plain-JS caller passing a non-string would have it fail the Swift side's
  `as? String` cast and run the query *unfiltered* — for a tenant- or
  user-scoping filter that silently returns documents the caller never
  intended to expose. Now throws when filterJson is present but not a
  string; undefined/null still mean "no filter".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread sdks/react-native/ios/MossClientSharedObject.swift Outdated
Comment thread sdks/react-native/src/MossClient.ts Outdated
The `files` allow-list included the whole `ios` directory, and the root
.npmignore did not exclude `ios/Frameworks/` from it. Confirmed by packing
with the framework present, as it is after any local `pod install`:

  before: 35 files, 18.4 MB packed / 73.7 MB unpacked
          (two 36.8 MB libmoss.a slices shipped)
  after:  28 files, 20.2 kB packed / 63.7 kB unpacked
          (zero Frameworks entries)

Beyond the size, a packed copy is the more serious problem: the download
script short-circuits when Frameworks/ already exists, so a consumer would
get whatever binary the publisher happened to have on disk, with the
checksum verification skipped entirely.

`files` now enumerates the iOS sources, podspec and scripts explicitly
rather than the whole directory. Added ios/.npmignore as a second line of
defence if that list is ever widened back. Verified the tarball still
carries both Swift sources, the podspec and the download script.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread sdks/react-native/package.json Outdated
@samanyugoyal2010

Copy link
Copy Markdown
Contributor Author

@HarshaNalluru this can be merged

Addresses the credentials finding on usemoss#473: the package previously offered
only a long-lived project key while also exposing createIndex / addDocs /
deleteIndex, so any production app had to ship a mutating secret in its JS
bundle.

`new MossClient({ projectId, getAuthToken })` now constructs a client via
moss_client_new_with_authenticator_and_device_id, so no long-lived secret
is embedded. The existing `new MossClient(projectId, projectKey)` form is
unchanged for development builds.

The native contract is already request-id based and asynchronous, which
maps onto Expo's event pattern without blocking anything:

  native needs a token
    -> MossAuthNotifyFn trampoline (arbitrary Rust thread, must not block)
      -> sendEvent("onMossAuthRequest", {clientId, requestId})
        -> JS awaits the caller's getAuthToken()
          -> moss_resolve_auth_request / moss_reject_auth_request

Details worth noting:

- The AuthBridgeBox handed over as `user_data` is retained manually and
  released only *after* moss_client_free, since the native side can invoke
  the callback until the client is gone. Released on every constructor
  error path too.
- JS answers on every path. An unanswered request would leave the native
  call that triggered it waiting forever, so a missing, throwing, or
  non-string-returning provider still produces a rejection — verified
  across 9 scenarios including reject() itself failing.
- requestId is range-checked before narrowing to UInt32 rather than
  trapping, consistent with the other bridged numbers.

Not verified here: MossModule.swift cannot be compiled without the real
ExpoModulesCore, and the end-to-end token round trip needs a device.
MossClientSharedObject.swift does type-check against the real MossC header
from the xcframework.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SDK: React Native and Expo module

2 participants