@psiphon-inc/expo-psiphon
v1.4.0
Published
Expo module for routing app networking through Psiphon Tunnel Core.
Readme
@psiphon-inc/expo-psiphon
@psiphon-inc/expo-psiphon is a reusable Expo module that routes React Native app traffic through Psiphon Tunnel Core, published publicly on npm and developed in the open at Psiphon-Inc/expo-psiphon (GitHub, mirrored to internal GitLab with identical commits/tags). GPL-3.0-or-later, with vendored tunnel-core binaries (ca.psiphon.aar, PsiphonTunnel.xcframework) pinned to a published upstream commit via SOURCE.md.
What it does
Apps supply their own tunnel-core config (SponsorId/PropagationChannelId, embedded server entries — nothing is baked into the module) and choose one of three routing modes:
- explicit (default) — only psiphonFetch() / psiphonDownload() go through the tunnel.
- global — React Native's standard networking is intercepted: fetch/XHR on both platforms, image loading on Android (OkHttp hooks installed at process start via ContentProvider), and WebSockets on both platforms — Android via the WebSocketModule client builder, iOS via a SocketRocket proxy hook (new in this release, the last piece of global coverage).
- manual — getProxyEndpoints() exposes the local SOCKS/HTTP proxy (TCP or Unix-domain-socket) for custom stacks.
Also on board:
- lightproxy support with byte accounting,
- push-payload import with persistent/in-memory queues,
- feedback upload with log collection,
- tunnel notices as JS events,
- a fixed-SOCKS-port option,
- UDS-based local proxies for explicit mode.
This package requires a development build or native build. Expo Go does not include the native Psiphon artifacts.
Install
npm install @psiphon-inc/expo-psiphonAdd the config plugin to your Expo app config:
{
"expo": {
"plugins": ["@psiphon-inc/expo-psiphon"]
}
}This package is tested with Expo SDK 53 and SDK 55. The peer dependency accepts Expo SDK 53 and newer; unlisted SDKs should be validated in your app before shipping.
On iOS the module declares its SwiftNIO dependency through React Native's
spm_dependency CocoaPods helper, so React Native 0.75 or newer is required
(every supported Expo SDK satisfies this). pod install needs network access
the first time so Xcode can resolve the Swift packages. Versions up to 1.0.x
injected a block of CNIO* :modular_headers lines into the app Podfile; that
block is no longer needed, and the config plugin removes a previously injected
one automatically (bare-workflow apps that copied it manually should delete
it).
Known issue on Expo SDK 53 / RN 0.79 (fixed upstream in newer templates):
CocoaPods' sequential object-UUID generator does not collision-check, and the
Swift package objects added during post_install can be assigned the root
PBXProject object's UUID — the written Pods project then fails to load
("The project 'Pods' is damaged") and packages never resolve. Apps using
the config plugin (any prebuild-managed app) get the repair automatically:
since 1.1.1 the plugin injects a save-time repair wrapper into the Podfile at
prebuild. Bare-workflow apps that maintain their own Podfile should add the
following at the end of the post_install block (after
react_native_post_install):
# Repair a CocoaPods UUID collision triggered by spm_dependency packages.
require 'securerandom'
project = installer.pods_project
root = project.root_object
offender = project.objects_by_uuid[root.uuid]
if offender && !offender.equal?(root)
fresh = SecureRandom.hex(12).upcase
fresh = SecureRandom.hex(12).upcase while project.objects_by_uuid.key?(fresh)
offender.instance_variable_set(:@uuid, fresh)
project.objects_by_uuid[fresh] = offender
project.objects_by_uuid[root.uuid] = root
endNative Artifacts
The npm package includes these native artifacts:
android/libs/ca.psiphon.aarios/Frameworks/PsiphonTunnel.xcframework
When installing from npm, these artifacts are part of the package tarball. When
working from a git clone, run git lfs pull after cloning if those files are
missing or look like small text pointer files. The config plugin validates the
vendored AAR and xcframework during prebuild when the plugin is listed in an
Expo app config, and fails with Git LFS guidance if the artifacts are not
present.
See SOURCE.md for the corresponding Psiphon Tunnel Core source commit, public source retrieval steps, artifact build commands, and reproducibility notes for these vendored binaries.
Building Artifacts Locally
Initialize or update submodules first:
git submodule update --init --recursiveBuild fresh artifacts from the related/psiphon-tunnel-core submodule and install them into the package paths:
npm run psiphon:artifactsAndroid defaults to Psiphon's Docker-based build:
npm run psiphon:artifacts:androidTo use host Android tooling instead, set ANDROID_HOME, ANDROID_PLATFORM_VERSION, and ensure gomobile is available:
npm run psiphon:artifacts:android -- --android-mode=hostiOS uses Psiphon's MobileLibrary/iOS/build-psiphon-framework.sh and requires Xcode. The wrapper downloads the exact Go version required by the submodule script into .toolchains/go/ when it is not already available:
npm run psiphon:artifacts:iosForward Go build tags with -- --build-tags="tag1 tag2". Install previously built submodule outputs without rebuilding with -- --install-only, or validate the currently installed package artifacts with npm run psiphon:artifacts:validate.
Wrapping Lightproxy Push Payloads
Generate local push payload key material:
npm run psiphon:push:generate-keys -- --out example/config/push_payload_keys.jsonAdd PushPayloadObfuscationKey and PushPayloadSignaturePublicKey from that file to example/config/psiphon_config.json. Keep PushPayloadSignaturePrivateKey private.
Wrap a raw encoded lightproxy entry into the signed and obfuscated push payload expected by importPushPayload:
npm run psiphon:push:wrap-lightproxy -- \
--keys example/config/push_payload_keys.json \
--entry-file example/config/lightproxy_entry \
--ttl-seconds 2592000 \
--out example/config/push_payloadBasic Usage
import {
addStatusListener,
addFeedbackStatusListener,
configurePsiphon,
enqueueFeedback,
getProxyEndpoints,
getStatus,
importPushPayload,
logError,
logInfo,
psiphonDownload,
psiphonFetch,
psiphonWebSocket,
startTunnel,
stopTunnel,
} from "@psiphon-inc/expo-psiphon";
await configurePsiphon({
tunnelCoreConfig,
embeddedServerEntries,
routingMode: "explicit",
failClosed: true,
});
await importPushPayload(pushPayloadBase64);
await startTunnel();
const status = await getStatus();
const proxyEndpoints = await getProxyEndpoints();
const subscription = addStatusListener((nextStatus) => {
console.log(nextStatus.canProxyRequests);
});
const response = await psiphonFetch("https://example.com/api", {
waitForProxy: true,
responseType: "text",
});
await psiphonDownload("https://example.com/file.zip", "file:///path/to/file.zip", {
waitForProxy: true,
});
const socket = await psiphonWebSocket("wss://example.com/stream", { waitForProxy: true });
const messageSub = socket.addMessageListener((message) => console.log(message.data));
await socket.send("hello");
messageSub.remove();
await socket.close();
subscription.remove();
await stopTunnel();Routing
routingMode selects how app traffic reaches the tunnel: global intercepts
React Native's standard HTTP clients, explicit (default) tunnels only
psiphonFetch/psiphonDownload calls, and manual exposes local proxy
endpoints for custom networking stacks.
Global routing caveats
- Call
configurePsiphonbefore the app issues any network request. React Native caches its HTTP clients on first use. On iOS, a URL session created before configuration permanently bypasses the proxy. On Android, the module installs its routing hook at process start, so later configuration still applies — but traffic sent beforeconfigurePsiphoncompletes is still direct unlessfailClosedengages. - Coverage limits: iOS global routing covers React Native fetch/XHR and
SocketRocket-backed
WebSocketwhen SocketRocket's private proxy selectors are present. Validate this when upgrading React Native or SocketRocket. Android global routing covers React Native's fetch/XHR/WebSocket and image loading via the standard OkHttp clients, but third-party SDKs with their own HTTP stacks bypass it, and installing a customOkHttpClientFactorydisplaces the hook. - For traffic that must be tunneled, prefer
psiphonFetch/psiphonDownload, which always dial the local proxy directly.
Limitations
expo-psiphon tunnels in-process app traffic only. Psiphon runs as a linked
library inside the app's own process — not a system/device VPN (no
NEPacketTunnelProvider on iOS, no VpnService on Android) — so it does not
tunnel other apps' or system traffic, and it does not exclude its own outbound
from a device-wide VPN. When another VPN is active, app traffic double-tunnels:
app → in-process Psiphon → device VPN → internet.
Feedback And App Logs
expo-psiphon can collect app logs, tunnel-core notices, and upload a Psiphon feedback report using the configured tunnel-core feedback settings.
const feedbackSub = addFeedbackStatusListener((event) => {
console.log(event.status, event.feedbackId, event.error);
});
logInfo("App", "Feedback-visible app log line");
logError("App", "Feedback-visible error line");
const feedbackId = await enqueueFeedback(id, { appName: "my-app" });
feedbackSub.remove();Tunnel-core logs require useNoticeFiles: true; without it the upload still
succeeds but the report's tunnel-core section is empty.
enqueueFeedback resolves once the upload has been accepted/started;
completion or failure is delivered via addFeedbackStatusListener events. On
Android it may resolve null when an upload is already pending.
App log messages passed to logInfo, logWarn, and logError may be written
to local feedback logs and included in uploaded feedback reports. Do not log
secrets, tokens, keys, private configs, credential-bearing URLs, or
user-sensitive data through these APIs.
API Notes
configurePsiphon accepts a tunnel-core config object or JSON string. SponsorId and PropagationChannelId are required. Defaults are routingMode: "explicit", failClosed: true, and persistPushPayloadQueue: true.
Additional config options:
proxyTypeselects which local tunnel-core proxy module traffic uses:"socks"(default) or"http"(HTTP CONNECT). It applies to all module traffic for the configuration's lifetime; there is no per-request override.useUnixDomainSocketsroutes the local proxies over Unix domain sockets instead of TCP ports. Only supported withroutingMode: "explicit";configurePsiphonrejects it with global routing.fixedSocksPortpins the local SOCKS proxy to a specific TCP port (1–65535) instead of an ephemeral one. On iOS, if that port is already in use the tunnel reportsERR_SOCKS_PROXY_PORT_IN_USEand stays fail-closed; the module does not auto-rotate, so handle the error by reconfiguring with a different port.- With
useNoticeFiles: true, notice-driven byte accounting for the light proxy is unavailable on iOS (known limitation).
psiphonFetch and psiphonDownload accept http and https URLs only.
psiphonDownload destinations must be file:// URLs or absolute filesystem
paths; empty and relative destinations are rejected before native calls.
Request options default to method: "GET", timeoutMs: 30000,
isolated: true, and responseType: "text". String bodies are supported;
Uint8Array bodies are rejected until native bridging support is added.
psiphonFetch response bodies are capped at 32 MiB (native-enforced); larger
responses fail with an error directing you to psiphonDownload, which streams
to disk.
psiphonWebSocket opens a WebSocket tunneled over the local proxy in any
routing mode — no global interception or SocketRocket private API is involved.
URLs must be ws or wss. waitForProxy and timeoutMs have the same
semantics as psiphonFetch; the timeout covers proxy wait, connect, and
handshake, defaulting to 30000 ms. The resolved handle exposes send (strings
send as text; binary messages use { kind: "binary", data } with base64 data),
close, and addMessageListener/addCloseListener subscriptions; call
.remove() on subscriptions you add. Sockets are fail-closed: a dropped
tunnel errors the socket (close event with error) — it never falls back to a
direct connection. One platform asymmetry: plain ws:// is not supported in
Unix-domain-socket mode on Android; use wss:// there.
The module automatically signals tunnel-core to resume when the app returns to the foreground; no configuration is required.
Other exports:
restartTunnel()stops and restarts the tunnel with the current configuration, forcing reconnection.getStatus()resolves the currentPsiphonStatussnapshot (state, proxy readiness,proxyEndpoints, byte totals, last error).getProxyEndpoints()resolves{ socks?, http?, selected? }. TCP endpoints include{ transport: "tcp", protocol, host, port }; Unix-domain-socket endpoints include{ transport: "unix", protocol, path }. In UDS mode,selectedis the HTTP endpoint when available because explicit helpers use HTTP CONNECT over UDS.getSocksProxy()resolves the TCP SOCKS proxy{ host, port }, ornullwhen it is not running. This is a legacy TCP-only convenience for manual integrations; prefergetProxyEndpoints()orgetStatus().proxyEndpoints.getPushPayloadQueueStatus()resolves the pending push payload queue count and the timestamp of the oldest queued payload.clearPushPayloadQueue()drops all queued push payloads.clearPsiphonData()clears persisted Psiphon data (server entries, persisted queues) for a clean-slate restart.addPushPayloadStatusListener(listener)subscribes to push payload import progress events; call.remove()on the returned subscription.addTunnelNoticeListener(listener)subscribes to forwarded tunnel-core notice messages whenforwardNoticesToEventsis enabled.
Example Test Harness
The example/ app is a native control room for verifying module fundamentals from UI controls. It exposes configuration, lifecycle, data reset, status/proxy observation, push queueing, live notices, explicit request helpers, raw fetch comparison, download testing, and one-tap smoke/cold-start race runs.
Use local environment values for real Psiphon configs:
cp example/.env.example example/.env.localThe example is a test harness. Fixture values loaded through example/config/,
Expo public env vars, or app config are bundled into the app and must not be
treated as secret storage. Its traffic simulation can intentionally run ordinary
untunneled networking when the tunnel is unavailable so proxy and baseline
paths can be compared during local testing.
Then run a native development build from example/:
npm install
npm run prebuild:clean
npm run iosNative UI automation uses Maestro flows under example/maestro/, driven by the
scripts/dev-sim.sh harness, which owns a dedicated simulator and headless
Metro so tests do not depend on a foreground npm run ios. From example/:
npm run dev:up # boot sim + Metro + build/install/launch
npm run dev:test:all # full local aggregate suite
npm run dev:test:ui
npm run dev:test:nativedev:test:all runs the full local aggregate suite; dev:test:benchmark stays
separate. See example/README.md for the full command list.
