feat: bind media HTTP server to UDS; expose via native content URLs#44
Open
gmaclennan wants to merge 9 commits intomainfrom
Open
feat: bind media HTTP server to UDS; expose via native content URLs#44gmaclennan wants to merge 9 commits intomainfrom
gmaclennan wants to merge 9 commits intomainfrom
Conversation
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>
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub. |
…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>
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
The backend's blob/icon HTTP server (Fastify, registered by
@comapeo/core'sMapeoManager) currently binds to127.0.0.1:<random>, so URLs returned byBlobApi.getUrl()/IconApi.getIconUrl()look likehttp://127.0.0.1:PORT/.... Two problems with that:UIActivityViewController) requires copying the bytes to a file and creating acontent://URI for it; we can't share anhttp://localhostURL.This PR moves the server onto a third Unix domain socket (
media.sock) alongside the existingcomapeo.sock/control.sock, then wraps it in a nativeContentProvideron Android and a globalURLProtocolon 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
mediaSocketPathargv positional and binds Fastify to it viafastify.listen({ path }). Fails fast if the arg is missing.getFastifyServerAddress()return\"\"when the address is a UDS path, soMapeoManager#getMediaBaseUrlproduces relative paths like/blobs/<projectPublicId>/.... The RN bridge then prepends scheme + authority.patch-packageadded as a backend devDep; a newpostbackend:installscript auto-applies the patch afternpm ci.Android
openFile()returns the read end of aParcelFileDescriptorpipe; a worker thread connects tomedia.sock, sends an HTTP/1.0 GET (HTTP/1.0 forbidsTransfer-Encoding: chunked, so the body is bytes-until-EOF — no chunk decoder needed), parses status + headers, then copies the body into the pipe.exported=\"false\"andgrantUriPermissions=\"true\"(the latter for a future share-sheet flow).getMediaContentAuthority()so the JS bridge can build URLs from the host app'sapplicationId.media.sockthrough argv and deletes it on cleanup alongside the other two.iOS
URLProtocolsubclass forcomapeo://media/<path>. Connects the UDS, sends HTTP/1.0 GET, parses headers, and streams the body viaurlProtocol(_:didLoad:)chunks. Reuses the existingconnectWithRetry/connectSockethelpers inNodeJSIPC.swift.static letside-effect, force-evaluated inapplicationDidBecomeActiveso it fires before the first image render.mediaSocketPath, includes it in the existing 104-bytesockaddr_un.sun_pathprecondition, and adds it to argv.getMediaContentAuthorityFunction (returns\"\"— iOS uses a fixed scheme).JS bridge
toNativeMediaUrl(relativePath):content://<applicationId>.comapeo.media<path>comapeo://media<path>Why these particular choices
getMediaBaseUrlreturns a string base URL) intact; the patch is one-line and easy to fold into an upstream PR later.content://orcomapeo://); thePlatform.OSswitch lives in JS where it belongs.URLProtocoloverRCTImageURLLoaderon iOS. Wider compatibility — works with anyURLSession.shared-based image loader, not just RN's built-in<Image>. Known risk flagged in the plan: aURLSessionconfigured withdefaultSessionConfigurationdoesn't auto-pick up globally-registeredURLProtocols, onlyURLSession.shareddoes. Needs on-device verification with the actual image loader; fallback is to add anRCTImageURLLoaderas a second layer.Out of scope (deferred)
grantUriPermissions=trueon the Android provider is groundwork; iOS will use the samecomapeo://URLs but needsUIActivityViewControllerwiring.backend/lib/maps-stub.js) due to the WASM/jitless issue; that's its own fix.@comapeo/core—patch-packageunblocks RN now; PR follows.Test plan
Verified locally:
npx tsc --noEmitclean (root + example).npm run lintclean.cd ios && swift test).npm run backend:installfrom a clean state runs and auto-applies the patch.cd backend && npm run buildproduces bothdist/ios/index.mjsanddist/android/index.mjsbundles.Needs on-device / simulator verification:
<Image source={{ uri: toNativeMediaUrl(path) }} />on Android and iOS; check RAM doesn't spike by the image size (streaming, not loading whole file).RCTImageURLLoaderif it doesn't).lsof -i/netstat -anshows no TCP listeners from the app process.🤖 Generated with Claude Code