Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r
# automatically.
T3CODE_RELAY_URL=https://relay.t3.codes

# Optional WebRTC RPC fast-path ICE configuration. STUN defaults to Cloudflare;
# TURN is disabled unless URLs are configured. Keep real TURN credentials out
# of committed env files.
# T3CODE_WEBRTC_STUN_URLS=stun:stun.cloudflare.com:3478
# T3CODE_WEBRTC_TURN_URLS=turn:turn.example.com:3478,turns:turn.example.com:5349
# T3CODE_WEBRTC_TURN_USERNAME=turn-user
# T3CODE_WEBRTC_TURN_CREDENTIAL=replace-me

# Optional: hosted app origin used by the CLI's out-of-band OAuth flow.
# Defaults to https://app.t3.codes; override to test against a staging deployment.
# T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/app.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ const config: ExpoConfig = {
},
plugins: [
"expo-asset",
"@config-plugins/react-native-webrtc",
[
"expo-font",
{
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dependencies": {
"@callstack/liquid-glass": "^0.7.1",
"@clerk/expo": "catalog:",
"@config-plugins/react-native-webrtc": "^15.0.1",
"@effect/atom-react": "catalog:",
"@expo-google-fonts/dm-sans": "^0.4.2",
"@expo/metro-runtime": "~56.0.15",
Expand Down Expand Up @@ -114,6 +115,7 @@
"react-native-screens": "4.25.2",
"react-native-shiki-engine": "^0.3.12",
"react-native-svg": "15.15.4",
"react-native-webrtc": "^124.0.8",
"react-native-webview": "^13.16.1",
"react-native-worklets": "0.8.3",
"shiki": "4.2.0",
Expand Down
138 changes: 137 additions & 1 deletion apps/mobile/src/connection/platform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,23 @@ import {
RelayDeviceIdentity,
SshEnvironmentGateway,
ThreadHistoryCacheStore,
WebRtcPeerFactory,
} from "@t3tools/client-runtime/platform";
import {
makeWebRtcPeerFactory,
selectedIcePairTypeFromStats,
type PlatformWebRtcPeerConnection,
type WebRtcSessionDescription,
} from "@t3tools/client-runtime/rpc";
import {
ConnectionBlockedError,
ConnectionTransientError,
Connectivity,
Wakeups,
} from "@t3tools/client-runtime/connection";
import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay";
import { AuthStandardClientScopes } from "@t3tools/contracts";
import { AuthStandardClientScopes, type WebRtcIceServer } from "@t3tools/contracts";
import type { WebRtcDataChannelPort } from "@t3tools/shared/webrtcDataChannel";
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
Expand All @@ -24,6 +32,7 @@ import * as Queue from "effect/Queue";
import * as Stream from "effect/Stream";
import * as Network from "expo-network";
import { AppState } from "react-native";
import { RTCPeerConnection, RTCSessionDescription } from "react-native-webrtc";

import { authClientMetadata } from "../lib/authClientMetadata";
import * as Runtime from "../lib/runtime";
Expand All @@ -34,6 +43,132 @@ import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts";
import { mobileApplicationActiveWakeup } from "./app-state-wakeups";
import { connectionStorageLayer } from "./storage";

type MobileDataChannel = ReturnType<RTCPeerConnection["createDataChannel"]>;

interface MobileDataChannelMessageEvent {
readonly data: string | ArrayBuffer | Blob;
}

interface MobileDataChannelEventTarget {
addEventListener(type: "message", listener: (event: MobileDataChannelMessageEvent) => void): void;
addEventListener(
type: "bufferedamountlow" | "close" | "error" | "open",
listener: () => void,
): void;
removeEventListener(
type: "message",
listener: (event: MobileDataChannelMessageEvent) => void,
): void;
removeEventListener(
type: "bufferedamountlow" | "close" | "error" | "open",
listener: () => void,
): void;
}

function mobileDataChannelPort(channel: MobileDataChannel): WebRtcDataChannelPort {
// RTCDataChannel inherits the package's EventTarget shim at runtime, but the
// published declaration omits that shim from its generated type artifacts.
const eventChannel = channel as MobileDataChannel & MobileDataChannelEventTarget;
channel.binaryType = "arraybuffer";
return {
label: channel.label,
ordered: channel.ordered,
isOpen: () => channel.readyState === "open",
bufferedAmount: () => channel.bufferedAmount,
setBufferedAmountLowThreshold: (bytes) => {
channel.bufferedAmountLowThreshold = bytes;
},
send: (data) => channel.send(data),
close: () => channel.close(),
onOpen: (listener) => {
eventChannel.addEventListener("open", listener);
return () => eventChannel.removeEventListener("open", listener);
},
onMessage: (listener) => {
const onMessage = (event: MobileDataChannelMessageEvent) => {
if (event.data instanceof ArrayBuffer) {
listener(new Uint8Array(event.data));
return;
}
if (typeof event.data === "string") {
listener(new TextEncoder().encode(event.data));
return;
}
void event.data
.arrayBuffer()
.then((buffer: ArrayBuffer) => listener(new Uint8Array(buffer)));
};
eventChannel.addEventListener("message", onMessage);
return () => eventChannel.removeEventListener("message", onMessage);
},
onClose: (listener) => {
eventChannel.addEventListener("close", listener);
return () => eventChannel.removeEventListener("close", listener);
},
onError: (listener) => {
const onError = () => listener(new Error("Mobile WebRTC DataChannel error."));
eventChannel.addEventListener("error", onError);
return () => eventChannel.removeEventListener("error", onError);
},
onBufferedAmountLow: (listener) => {
eventChannel.addEventListener("bufferedamountlow", listener);
return () => eventChannel.removeEventListener("bufferedamountlow", listener);
},
};
}

function mobileSessionDescription(
description: RTCSessionDescription,
): WebRtcSessionDescription | null {
if (description.type !== "offer" && description.type !== "answer") {
return null;
}
return { type: description.type, sdp: description.sdp };
}

function createMobilePeerConnection(
iceServers: ReadonlyArray<WebRtcIceServer>,
): PlatformWebRtcPeerConnection {
const peer = new RTCPeerConnection({
iceServers: iceServers.map((server) => ({
urls: [...server.urls],
...(server.username !== undefined && server.credential !== undefined
? { username: server.username, credential: server.credential }
: {}),
})),
});
return {
createDataChannel: (label) =>
mobileDataChannelPort(peer.createDataChannel(label, { ordered: true })),
createOffer: () =>
peer
.createOffer()
.then((description) => mobileSessionDescription(new RTCSessionDescription(description))),
setLocalDescription: (description) =>
peer.setLocalDescription(new RTCSessionDescription(description)),
localDescription: () =>
peer.localDescription === null ? null : mobileSessionDescription(peer.localDescription),
setRemoteDescription: (description) =>
peer.setRemoteDescription(new RTCSessionDescription(description)),
iceGatheringState: () => peer.iceGatheringState,
onIceGatheringStateChange: (listener) => {
peer.onicegatheringstatechange = listener;
return () => {
peer.onicegatheringstatechange = null;
};
},
onConnectionStateChange: (listener) => {
const onStateChange = () => listener(peer.connectionState);
peer.onconnectionstatechange = onStateChange;
return () => {
peer.onconnectionstatechange = null;
};
},
selectedIcePairType: () => peer.getStats().then(selectedIcePairTypeFromStats),
close: () => peer.close(),
};
}

function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" {
if (state.isConnected === false) {
return "offline";
Expand Down Expand Up @@ -191,6 +326,7 @@ const capabilitiesLayer = Layer.effectContext(
disconnect: () => Effect.void,
}),
),
Context.add(WebRtcPeerFactory, makeWebRtcPeerFactory(createMobilePeerConnection)),
);
}),
);
Expand Down
3 changes: 3 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
"effect-codex-app-server": "workspace:*",
"vite-plus": "catalog:"
},
"optionalDependencies": {
"werift": "^0.24.3"
},
"engines": {
"node": "^22.16 || ^23.11 || >=24.10"
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ export const RPC_REQUIRED_SCOPES = {
[ORCHESTRATION_WS_METHODS.subscribeThreadWithDelta]: AuthOrchestrationReadScope,
[WS_METHODS.serverProbe]: AuthOrchestrationReadScope,
[WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope,
[WS_METHODS.transportWebRtcNegotiate]: AuthOrchestrationReadScope,
[WS_METHODS.transportWebRtcAbort]: AuthOrchestrationReadScope,
[WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope,
[WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope,
Expand Down
49 changes: 49 additions & 0 deletions apps/server/src/cli/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,24 @@ import * as NetService from "@t3tools/shared/Net";
import { ROOT_BASE_PATH } from "@t3tools/shared/basePath";
import * as NodeServices from "@effect/platform-node/NodeServices";
import { deriveServerPaths } from "../config.ts";
import { parseWebRtcUdpPortRange } from "../webrtc/config.ts";
import { resolveServerConfig } from "./config.ts";

const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) =>
deriveServerPaths(baseDir, devUrl, { baseDirIsExplicit: true });

const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap));

it.effect("parses and bounds the WebRTC UDP port range", () =>
Effect.gen(function* () {
expect(yield* parseWebRtcUdpPortRange("60000-61000")).toEqual([60_000, 61_000]);
expect((yield* Effect.flip(parseWebRtcUdpPortRange("61000-60000"))).reason).toBe(
"invalid-range",
);
expect((yield* Effect.flip(parseWebRtcUdpPortRange("80-81"))).reason).toBe("invalid-range");
}),
);

const makeDesktopBootstrap = (
overrides: Partial<DesktopBackendBootstrapValue> = {},
): DesktopBackendBootstrapValue => ({
Expand Down Expand Up @@ -53,6 +64,14 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
otlpServiceName: "t3-server",
devAllowedOrigins: [],
} as const;
const defaultOptionalRuntimeConfig = {
desktopTelemetryFd: undefined,
desktopTelemetryControlFd: undefined,
resourceMonitorPath: undefined,
webRtcFastPathEnabled: true,
webRtcIceServers: [{ urls: ["stun:stun.cloudflare.com:3478"] }],
webRtcUdpPortRange: [60_000, 61_000],
} as const;
const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) {
const fs = yield* FileSystem.FileSystem;
const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" });
Expand Down Expand Up @@ -106,6 +125,14 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
T3CODE_NO_BROWSER: "true",
T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
T3CODE_LOG_WS_EVENTS: "true",
T3CODE_WEBRTC_FAST_PATH: "0",
T3CODE_WEBRTC_STUN_URLS:
"stun:stun1.example.test:3478, stuns:stun2.example.test:5349",
T3CODE_WEBRTC_TURN_URLS:
"turn:turn1.example.test:3478?transport=udp, turns:turn2.example.test:5349?transport=tcp",
T3CODE_WEBRTC_TURN_USERNAME: "turn-user",
T3CODE_WEBRTC_TURN_CREDENTIAL: "turn-credential",
T3CODE_WEBRTC_UDP_PORT_RANGE: "62000-62100",
},
}),
),
Expand All @@ -117,6 +144,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved).toEqual({
logLevel: "Warn",
...defaultObservabilityConfig,
...defaultOptionalRuntimeConfig,
mode: "desktop",
port: 4001,
cwd: process.cwd(),
Expand All @@ -134,6 +162,21 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
basePath: ROOT_BASE_PATH,
tailscaleServeEnabled: false,
tailscaleServePort: 443,
webRtcFastPathEnabled: false,
webRtcIceServers: [
{
urls: ["stun:stun1.example.test:3478", "stuns:stun2.example.test:5349"],
},
{
urls: [
"turn:turn1.example.test:3478?transport=udp",
"turns:turn2.example.test:5349?transport=tcp",
],
username: "turn-user",
credential: "turn-credential",
},
],
webRtcUdpPortRange: [62_000, 62_100],
});
assert.equal(resolved.stateDir, join(baseDir, "userdata"));
}),
Expand Down Expand Up @@ -190,6 +233,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved).toEqual({
logLevel: "Debug",
...defaultObservabilityConfig,
...defaultOptionalRuntimeConfig,
mode: "web",
port: 8788,
cwd: process.cwd(),
Expand Down Expand Up @@ -265,6 +309,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved).toEqual({
logLevel: "Info",
...defaultObservabilityConfig,
...defaultOptionalRuntimeConfig,
mode: "web",
port: 8788,
cwd: process.cwd(),
Expand Down Expand Up @@ -341,6 +386,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved).toEqual({
logLevel: "Info",
...defaultObservabilityConfig,
...defaultOptionalRuntimeConfig,
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
mode: "desktop",
Expand Down Expand Up @@ -479,6 +525,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved).toEqual({
logLevel: "Debug",
...defaultObservabilityConfig,
...defaultOptionalRuntimeConfig,
mode: "web",
port: 8788,
cwd: process.cwd(),
Expand Down Expand Up @@ -548,6 +595,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved).toEqual({
logLevel: "Info",
...defaultObservabilityConfig,
...defaultOptionalRuntimeConfig,
otlpTracesUrl: "http://localhost:4318/v1/traces",
otlpMetricsUrl: "http://localhost:4318/v1/metrics",
mode: "desktop",
Expand Down Expand Up @@ -615,6 +663,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => {
expect(resolved).toEqual({
logLevel: "Info",
...defaultObservabilityConfig,
...defaultOptionalRuntimeConfig,
mode: "web",
port: 3773,
cwd: process.cwd(),
Expand Down
Loading
Loading