Skip to content

feat: bind media HTTP server to UDS; expose via native content URLs#44

Open
gmaclennan wants to merge 9 commits intomainfrom
claude/stupefied-greider-804127
Open

feat: bind media HTTP server to UDS; expose via native content URLs#44
gmaclennan wants to merge 9 commits intomainfrom
claude/stupefied-greider-804127

Conversation

@gmaclennan
Copy link
Copy Markdown
Member

Summary

The backend's blob/icon HTTP server (Fastify, registered by @comapeo/core's MapeoManager) currently binds to 127.0.0.1:<random>, so URLs returned by BlobApi.getUrl() / IconApi.getIconUrl() look like http://127.0.0.1:PORT/.... Two problems with that:

  1. Cross-app exposure — any other app on the phone can read those URLs while the backend is running.
  2. Sharing friction — passing an image to the Android share sheet (or iOS UIActivityViewController) requires copying the bytes to a file and creating a content:// URI for it; we can't share an http://localhost URL.

This PR moves the server onto a third Unix domain socket (media.sock) alongside the existing comapeo.sock / control.sock, then wraps it in a native ContentProvider on Android and a global URLProtocol on iOS so React Native's <Image> can stream bytes directly. Memory stays bounded for large images — both platforms forward chunks as they arrive instead of buffering.

Resolves the "blobs/icons over UDS" follow-up tracked in agents.md (#31).

What changed

Backend

  • backend/index.js accepts a new mediaSocketPath argv positional and binds Fastify to it via fastify.listen({ path }). Fails fast if the arg is missing.
  • backend/patches/@comapeo+core+7.1.0.patch makes getFastifyServerAddress() return \"\" when the address is a UDS path, so MapeoManager#getMediaBaseUrl produces relative paths like /blobs/<projectPublicId>/.... The RN bridge then prepends scheme + authority. patch-package added as a backend devDep; a new postbackend:install script auto-applies the patch after npm ci.

Android

  • New MediaContentProvider.ktopenFile() returns the read end of a ParcelFileDescriptor pipe; a worker thread connects to media.sock, sends an HTTP/1.0 GET (HTTP/1.0 forbids Transfer-Encoding: chunked, so the body is bytes-until-EOF — no chunk decoder needed), parses status + headers, then copies the body into the pipe.
  • AndroidManifest.xml declares the provider with exported=\"false\" and grantUriPermissions=\"true\" (the latter for a future share-sheet flow).
  • ComapeoCoreModule.kt exposes getMediaContentAuthority() so the JS bridge can build URLs from the host app's applicationId.
  • NodeJSService.kt threads media.sock through argv and deletes it on cleanup alongside the other two.

iOS

  • New MediaURLProtocol.swiftURLProtocol subclass for comapeo://media/<path>. Connects the UDS, sends HTTP/1.0 GET, parses headers, and streams the body via urlProtocol(_:didLoad:) chunks. Reuses the existing connectWithRetry/connectSocket helpers in NodeJSIPC.swift.
  • AppLifecycleDelegate.swift registers the protocol class once via a static let side-effect, force-evaluated in applicationDidBecomeActive so it fires before the first image render.
  • NodeJSService.swift stores mediaSocketPath, includes it in the existing 104-byte sockaddr_un.sun_path precondition, and adds it to argv.
  • ComapeoCoreModule.swift mirrors the Android getMediaContentAuthority Function (returns \"\" — iOS uses a fixed scheme).

JS bridge

  • New src/mediaUrl.ts exports toNativeMediaUrl(relativePath):
    • Android → content://<applicationId>.comapeo.media<path>
    • iOS → comapeo://media<path>
  • Re-exported from src/index.ts.

Why these particular choices

  • HTTP/1.0 in the native clients. Forces Fastify into "Connection: close, body delimited by EOF" mode so neither side needs a chunked-encoding state machine. The trade-off is no keep-alive — fine, because every request opens a fresh UDS connection anyway.
  • Patch via patch-package, not bypassing FastifyController. Keeps the upstream contract (getMediaBaseUrl returns a string base URL) intact; the patch is one-line and easy to fold into an upstream PR later.
  • Translate at the RN bridge, not in the backend. Backend stays platform-agnostic (knows nothing about content:// or comapeo://); the Platform.OS switch lives in JS where it belongs.
  • URLProtocol over RCTImageURLLoader on iOS. Wider compatibility — works with any URLSession.shared-based image loader, not just RN's built-in <Image>. Known risk flagged in the plan: a URLSession configured with defaultSessionConfiguration doesn't auto-pick up globally-registered URLProtocols, only URLSession.shared does. Needs on-device verification with the actual image loader; fallback is to add an RCTImageURLLoader as a second layer.

Out of scope (deferred)

  • Share-sheet integration — the grantUriPermissions=true on the Android provider is groundwork; iOS will use the same comapeo:// URLs but needs UIActivityViewController wiring.
  • Maps URL handling — iOS still stubs the maps fastify plugin (backend/lib/maps-stub.js) due to the WASM/jitless issue; that's its own fix.
  • Upstream PR to @comapeo/corepatch-package unblocks RN now; PR follows.

Test plan

Verified locally:

  • npx tsc --noEmit clean (root + example).
  • npm run lint clean.
  • All 43 iOS Swift Package tests pass (cd ios && swift test).
  • npm run backend:install from a clean state runs and auto-applies the patch.
  • cd backend && npm run build produces both dist/ios/index.mjs and dist/android/index.mjs bundles.

Needs on-device / simulator verification:

  • Render a 10 MB+ photo through <Image source={{ uri: toNativeMediaUrl(path) }} /> on Android and iOS; check RAM doesn't spike by the image size (streaming, not loading whole file).
  • iOS `URLProtocol` actually receives requests from RN's image loader (the known risk — fallback is RCTImageURLLoader if it doesn't).
  • Cross-app isolation: confirm lsof -i / netstat -an shows no TCP listeners from the app process.
  • 404 path: request a non-existent blob; expect a broken-image render and an error event, not a crash.
  • Backend restart resilience: kill the foreground service, reopen the app, verify URLs work again.

🤖 Generated with Claude Code

Move the backend's blob/icon HTTP server (Fastify, registered by
@comapeo/core's MapeoManager) off a localhost TCP port and onto a third
Unix domain socket alongside the existing comapeo.sock and control.sock.
Wrap the socket in a native ContentProvider on Android and a global
URLProtocol on iOS so React Native's <Image> can stream the bytes
directly without exposing an HTTP endpoint other apps on the device can
read, and without copying images to disk before they can be shared.

Backend
- backend/index.js accepts a fifth argv positional (mediaSocketPath)
  and binds Fastify to it. Fails fast if the arg is missing.
- patches/@comapeo+core+7.1.0.patch makes getFastifyServerAddress()
  return "" when the address is a UDS path so MapeoManager#getMediaBaseUrl
  produces relative paths like /blobs/<projectPublicId>/...; the RN
  bridge then translates these to platform-native URLs. patch-package
  added as a backend devDep and applied automatically by a new
  postbackend:install script.

Android
- New MediaContentProvider exposes the UDS-bound server as
  content://${applicationId}.comapeo.media/<path>. openFile() returns
  the read end of a ParcelFileDescriptor pipe; a worker thread connects
  to media.sock, sends an HTTP/1.0 GET (HTTP/1.0 forbids chunked
  encoding, so the body is bytes-until-EOF — no chunk decoder needed),
  parses status+headers, then copies the body into the pipe.
- AndroidManifest.xml declares the provider exported=false and
  grantUriPermissions=true (the latter for a future share-sheet flow).
- ComapeoCoreModule exposes getMediaContentAuthority() so the JS bridge
  can build URLs from the host app's applicationId.
- NodeJSService passes media.sock as a fifth argv positional and deletes
  it on cleanup alongside the other two sockets.

iOS
- New MediaURLProtocol intercepts comapeo://media/<path> URL loads,
  connects the same UDS, sends HTTP/1.0 GET, parses headers, and streams
  the body via urlProtocol(_:didLoad:) chunks for bounded memory on large
  images. Reuses the existing connectWithRetry/connectSocket helpers.
- AppLifecycleDelegate registers the protocol class once via a
  static-let side-effect, force-evaluated in applicationDidBecomeActive
  so it fires before the first image render.
- NodeJSService stores mediaSocketPath, includes it in the
  sockaddr_un.sun_path 104-byte precondition, and threads it through
  argv. ComapeoCoreModule mirrors the Android getMediaContentAuthority
  Function (returns "" — iOS uses a fixed scheme).

JS bridge
- src/mediaUrl.ts exports toNativeMediaUrl(relativePath) which returns
  content://<authority><path> on Android and comapeo://media<path> on
  iOS. Authority cached after first lookup.

Out of scope (per plan): share-sheet integration, maps URL handling on
iOS (the maps plugin is still stubbed there), upstream PR to
@comapeo/core. Resolves the "blobs/icons over UDS" follow-up tracked in
agents.md (#31).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
gmaclennan and others added 8 commits April 29, 2026 15:22
…m deps

The previous commit's lockfile was regenerated with npm 10 (Node 20.19.4)
which dropped the `@esbuild/*` optional platform packages. CI runs `npm ci
--ignore-scripts --prefix backend` on Node 24 / npm 11 (per
`devEngines.runtime`) and rejected the lockfile with `Missing:
@esbuild/<platform>@0.28.0 from lock file`. Even where `npm ci` got past
that, the rollup-plugin-esbuild step then failed at
"The package @esbuild/darwin-arm64 could not be found, and is needed by
esbuild" because the platform binary was never installed.

Regenerate with the canonical Node 24 / npm 11 toolchain so the optional
platform entries stay in the lockfile and `npm ci --ignore-scripts`
materialises the macOS binary alongside everything else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the trivial \"projects: 0\" demo with a screen that exercises the
full media-URL stack end to end:

- find/create a fixture project
- materialise the bundled icon.png to a real filesystem path via
  expo-asset (file:// then strip scheme — backend wants a plain path)
- $blobs.create({ original: filepath }, { mimeType: 'image/png' })
- $blobs.getUrl(blobId) — should return /blobs/<projectPublicId>/...
  after the @comapeo/core patch in this PR
- toNativeMediaUrl() rewrite to content://… (Android) or
  comapeo://media/… (iOS)
- <Image source={{ uri }} /> renders the result

Both URLs are also rendered as selectable monospace text so a reviewer
can eyeball them without DevTools. \"Reload <Image>\" forces a remount
(via key) so the loader re-fetches instead of reusing the in-memory
cache; \"Re-create blob\" walks the whole pipeline again.

Adds expo-asset (12.0.10) as an example-only dependency. Not added to
the published @comapeo/core-react-native package — only the test
fixture needs it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`expo-asset@12.0.10` is the SDK 54 line; pulling it under expo@55.0.17
forced a transitively-resolved expo-constants@18.0.13 alongside the
project's expo-modules-core@55.0.23, and the two snapshots disagree on
the abstract members of `ConstantsService`. Gradle's
`:expo-constants:compileDebugKotlin` then failed with
"'getConstants' overrides nothing", "Unresolved reference 'constants'",
etc.

Pin to expo-asset@55.0.16 — the version expo@55.0.17 declares in its own
`dependencies` field — so every expo-* package resolves to the same
55.x snapshot.

iOS Device Build also failed in the same run with `curl: (56) The
requested URL returned error: 502` from a transient prebuild fetch. Not
fixed here; it should clear on rerun.

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

`URLProtocol.registerClass(_:)` only catches `URLSession`-backed
callers. RN's `RCTImageLoader` does its own scheme→loader lookup *first*
and never reaches `URLSession` for unknown schemes, so the example app
errored on every <Image> with "No suitable image URL loader found for
comapeo://media/...".

Extract the UDS connect → write → header-parse pipeline into a shared
`MediaFetcher` Swift class (`@objc(ComapeoMediaFetcher)`), and add
`ComapeoMediaImageLoader.mm` that:

- registers via `RCT_EXPORT_MODULE()` so RN picks it up at +load time
- claims `comapeo://media/...` from `canLoadImageURL:` with priority 1
- buffers the body via `MediaFetcher.fetchURL:completion:` and decodes a
  UIImage for `completionHandler`

Memory shape matches RN's existing `RCTNetworkImageLoader` for http(s):
both buffer encoded NSData then call `[UIImage imageWithData:]`. RN has
no incremental-decode path for static images, so the URL-protocol
streaming was a theoretical optimization the image loader never used.
The streaming `URLProtocol` stays registered for any non-RN-Image
caller (share sheet, third-party libs, future SDWebImage hookup) — both
paths share the new `MediaFetcher.open()` helper.

`AppLifecycleDelegate` installs the socket-path closure on
`MediaFetcher.socketPathProvider` (the new single source of truth) and
`MediaURLProtocol` reads from there too.

Header import in the .mm uses `__has_include` to cover both
framework-style (`<ComapeoCore/ComapeoCore-Swift.h>`) and static-lib
(`"ComapeoCore-Swift.h"`) Pod build configurations.

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

* origin/main:
  Add rootkey persistence and lifecycle state management (#36)
  android: audit 16 KB page alignment on every shipped .so (#43)
  chore: fix eslint configuration (#41)

# Conflicts:
#	android/src/main/java/com/comapeo/core/ComapeoCoreModule.kt
#	backend/index.js
#	ios/ComapeoCoreModule.swift
#	src/index.ts
…der-804127

* origin/main:
  chore: move example app into apps directory (#18)
`ComapeoCore-Swift.h` (auto-generated) re-exposes every @objc Swift class
in the module to Obj-C, including `AppLifecycleDelegate` which extends
`BaseExpoAppDelegateSubscriber` from `ExpoModulesCore`. Without that
parent class in scope, Clang errored at:

    error: cannot find interface declaration for
    'EXBaseAppDelegateSubscriber', superclass of 'AppLifecycleDelegate'
    error: no type or protocol named 'EXAppDelegateSubscriberProtocol'

`@import ExpoModulesCore;` brings the parent types in before the
bridging-header import.

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

`@import ExpoModulesCore` was rejected by this Pod's compile flags
("use of '@import' when C++ modules are disabled"), and the previous
plain `#import "ComapeoCore-Swift.h"` dragged in the auto-exposed
`AppLifecycleDelegate` declaration whose superclass (Expo's
`BaseExpoAppDelegateSubscriber`) isn't a public header type — Clang
errored with "cannot find interface declaration for
EXBaseAppDelegateSubscriber".

Skip the bridging header entirely. Forward-declare only the
`ComapeoMediaFetcher` class with the two class methods this file
actually calls. Swift's `@objc` emits matching runtime symbols, so
linking is unaffected; the compile-time surface stays minimal so
unrelated Swift class exposures can't break the build.

Co-Authored-By: Claude Opus 4.7 (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.

1 participant