From ab5746f2de0a102440d38d05ea4621de44dcbc60 Mon Sep 17 00:00:00 2001 From: Taras Date: Tue, 18 Aug 2026 23:30:04 +0300 Subject: [PATCH 1/5] feat(rpc): upgrade websocket transport to webrtc --- apps/mobile/app.config.ts | 1 + apps/mobile/package.json | 3 + apps/mobile/src/connection/platform.ts | 133 ++++ apps/server/package.json | 1 + apps/server/src/ws.ts | 23 +- apps/web/package.json | 1 + apps/web/src/connection/platform.ts | 100 +++ docs/internals/websocket-webrtc-upgrade.md | 47 ++ packages/client-runtime/package.json | 1 + packages/client-runtime/src/rpc/session.ts | 43 +- packages/websocket-webrtc/package.json | 44 ++ packages/websocket-webrtc/src/client.ts | 414 ++++++++++++ packages/websocket-webrtc/src/effectHttp.ts | 22 + packages/websocket-webrtc/src/peer.ts | 81 +++ packages/websocket-webrtc/src/server.ts | 285 +++++++++ packages/websocket-webrtc/src/socket.ts | 668 ++++++++++++++++++++ packages/websocket-webrtc/src/werift.ts | 221 +++++++ packages/websocket-webrtc/src/wire.ts | 287 +++++++++ packages/websocket-webrtc/tsconfig.json | 4 + pnpm-lock.yaml | 388 +++++++++++- 20 files changed, 2761 insertions(+), 6 deletions(-) create mode 100644 docs/internals/websocket-webrtc-upgrade.md create mode 100644 packages/websocket-webrtc/package.json create mode 100644 packages/websocket-webrtc/src/client.ts create mode 100644 packages/websocket-webrtc/src/effectHttp.ts create mode 100644 packages/websocket-webrtc/src/peer.ts create mode 100644 packages/websocket-webrtc/src/server.ts create mode 100644 packages/websocket-webrtc/src/socket.ts create mode 100644 packages/websocket-webrtc/src/werift.ts create mode 100644 packages/websocket-webrtc/src/wire.ts create mode 100644 packages/websocket-webrtc/tsconfig.json diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 9a5172547..427055ebd 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -235,6 +235,7 @@ const config: ExpoConfig = { }, plugins: [ "expo-asset", + "@config-plugins/react-native-webrtc", [ "expo-font", { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index de53a37c9..b0afea0df 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -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", @@ -67,6 +68,7 @@ "@t3tools/mobile-review-diff-native": "file:./modules/t3-review-diff", "@t3tools/mobile-terminal-native": "file:./modules/t3-terminal", "@t3tools/shared": "workspace:*", + "@t3tools/websocket-webrtc": "workspace:*", "@tabler/icons-react-native": "^3.44.0", "clsx": "^2.1.1", "diff": "8.0.3", @@ -114,6 +116,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", diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index a31cfb361..358c2a0ae 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -15,6 +15,17 @@ import { Wakeups, } from "@t3tools/client-runtime/connection"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { + makeClientWebRtcPeerFactory, + type PlatformWebRtcPeerConnection, + type WebRtcSessionDescription, +} from "@t3tools/websocket-webrtc/client"; +import { + type WebRtcDataChannelPort, + type WebRtcIceServer, + WebRtcClientPlatform, + WebRtcPeerError, +} from "@t3tools/websocket-webrtc/peer"; import { AuthStandardClientScopes } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -22,8 +33,10 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as ExpoCrypto from "expo-crypto"; 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"; @@ -34,6 +47,125 @@ import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; import { connectionStorageLayer } from "./storage"; +type MobileDataChannel = ReturnType; + +interface MobileDataChannelMessageEvent { + readonly data: string | ArrayBuffer | Blob; +} + +interface MobileDataChannelEventTarget { + addEventListener(type: "message", listener: (event: MobileDataChannelMessageEvent) => void): void; + addEventListener(type: "close" | "error" | "open", listener: () => void): void; + removeEventListener( + type: "message", + listener: (event: MobileDataChannelMessageEvent) => void, + ): void; + removeEventListener(type: "close" | "error" | "open", listener: () => void): void; +} + +function mobileDataChannelPort(channel: MobileDataChannel): WebRtcDataChannelPort { + // react-native-webrtc implements EventTarget here, but its generated declaration omits it. + const eventChannel = channel as MobileDataChannel & MobileDataChannelEventTarget; + channel.binaryType = "arraybuffer"; + return { + label: channel.label, + ordered: channel.ordered, + isOpen: () => channel.readyState === "open", + bufferedAmount: () => channel.bufferedAmount, + 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); + }, + }; +} + +function mobileSessionDescription( + description: RTCSessionDescription, +): WebRtcSessionDescription | null { + if (description.type !== "offer" && description.type !== "answer") { + return null; + } + return { type: description.type, sdp: description.sdp }; +} + +function createMobileRtcPeerConnection( + iceServers: ReadonlyArray, +): PlatformWebRtcPeerConnection { + const peer = new RTCPeerConnection({ + iceServers: iceServers.map((server) => ({ + urls: [...server.urls], + ...(server.username === undefined ? {} : { username: server.username }), + ...(server.credential === undefined ? {} : { 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; + }; + }, + close: () => peer.close(), + }; +} + +const mobileWebRtcClientPlatform = makeClientWebRtcPeerFactory({ + createPeerConnection: createMobileRtcPeerConnection, + randomBytes: (size) => + Effect.try({ + try: () => ExpoCrypto.getRandomBytes(size), + catch: (cause) => new WebRtcPeerError({ stage: "create", cause }), + }), +}); + function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { if (state.isConnected === false) { return "offline"; @@ -191,6 +323,7 @@ const capabilitiesLayer = Layer.effectContext( disconnect: () => Effect.void, }), ), + Context.add(WebRtcClientPlatform, mobileWebRtcClientPlatform), ); }), ); diff --git a/apps/server/package.json b/apps/server/package.json index d6e3abe38..472221cc9 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -42,6 +42,7 @@ "@t3tools/shared": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", + "@t3tools/websocket-webrtc": "workspace:*", "@types/bun": "1.3.14", "@types/node": "catalog:", "effect-acp": "workspace:*", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index bd180f2da..265adb161 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -60,6 +60,10 @@ import { WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; +import { mapSocketUpgrade } from "@t3tools/websocket-webrtc/effect-http"; +import { makeServerLogicalSocket } from "@t3tools/websocket-webrtc/server"; +import { loadWeriftServerPeerFactory } from "@t3tools/websocket-webrtc/werift"; +import { readUpgradeNonce } from "@t3tools/websocket-webrtc/wire"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; @@ -2324,6 +2328,20 @@ export const websocketRpcRouteLayer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); + const upgradeNonce = readUpgradeNonce(new URL(request.url, "http://localhost")); + let rpcRequest = request; + if (upgradeNonce !== null) { + const peerFactory = yield* loadWeriftServerPeerFactory; + if (Option.isSome(peerFactory)) { + rpcRequest = mapSocketUpgrade(request, (socket) => + makeServerLogicalSocket({ + socket, + nonce: upgradeNonce, + peerFactory: peerFactory.value, + }), + ); + } + } const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { disableTracing: true, }).pipe( @@ -2361,7 +2379,10 @@ export const websocketRpcRouteLayer = Layer.unwrap( ); return yield* Effect.acquireUseRelease( sessions.markConnected(session.sessionId), - () => rpcWebSocketHttpEffect, + () => + rpcWebSocketHttpEffect.pipe( + Effect.provideService(HttpServerRequest.HttpServerRequest, rpcRequest), + ), () => sessions.markDisconnected(session.sessionId), ); }).pipe( diff --git a/apps/web/package.json b/apps/web/package.json index 44b85cf62..83603dd17 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -29,6 +29,7 @@ "@t3tools/client-runtime": "workspace:*", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/websocket-webrtc": "workspace:*", "@tanstack/react-pacer": "^0.19.4", "@tanstack/react-router": "^1.160.2", "class-variance-authority": "^0.7.1", diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index c7652136f..8195bd274 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -24,6 +24,17 @@ import { import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; +import { + makeClientWebRtcPeerFactory, + type PlatformWebRtcPeerConnection, + type WebRtcSessionDescription, +} from "@t3tools/websocket-webrtc/client"; +import { + type WebRtcDataChannelPort, + type WebRtcIceServer, + WebRtcClientPlatform, + WebRtcPeerError, +} from "@t3tools/websocket-webrtc/peer"; import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; import { AuthStandardClientScopes, @@ -61,6 +72,94 @@ import { connectionStorageLayer } from "./storage"; let nextObservedRpcRequestId = 0; +function webDataChannelPort(channel: RTCDataChannel): WebRtcDataChannelPort { + channel.binaryType = "arraybuffer"; + return { + label: channel.label, + ordered: channel.ordered, + isOpen: () => channel.readyState === "open", + bufferedAmount: () => channel.bufferedAmount, + send: (data) => channel.send(Uint8Array.from(data)), + close: () => channel.close(), + onOpen: (listener) => { + channel.addEventListener("open", listener); + return () => channel.removeEventListener("open", listener); + }, + onMessage: (listener) => { + const onMessage = (event: MessageEvent) => { + listener(new Uint8Array(event.data)); + }; + channel.addEventListener("message", onMessage); + return () => channel.removeEventListener("message", onMessage); + }, + onClose: (listener) => { + channel.addEventListener("close", listener); + return () => channel.removeEventListener("close", listener); + }, + onError: (listener) => { + const onError = () => listener(new Error("Browser WebRTC DataChannel error.")); + channel.addEventListener("error", onError); + return () => channel.removeEventListener("error", onError); + }, + }; +} + +function webSessionDescription( + description: RTCSessionDescription | RTCSessionDescriptionInit, +): WebRtcSessionDescription | null { + if ( + (description.type !== "offer" && description.type !== "answer") || + description.sdp === undefined + ) { + return null; + } + return { type: description.type, sdp: description.sdp }; +} + +function createWebRtcPeerConnection( + iceServers: ReadonlyArray, +): PlatformWebRtcPeerConnection { + const peer = new RTCPeerConnection({ + iceServers: iceServers.map((server) => ({ + urls: [...server.urls], + ...(server.username === undefined ? {} : { username: server.username }), + ...(server.credential === undefined ? {} : { credential: server.credential }), + })), + }); + return { + createDataChannel: (label) => + webDataChannelPort(peer.createDataChannel(label, { ordered: true })), + createOffer: () => peer.createOffer().then(webSessionDescription), + setLocalDescription: (description) => peer.setLocalDescription(description), + localDescription: () => + peer.localDescription === null ? null : webSessionDescription(peer.localDescription), + setRemoteDescription: (description) => peer.setRemoteDescription(description), + iceGatheringState: () => peer.iceGatheringState, + onIceGatheringStateChange: (listener) => { + peer.addEventListener("icegatheringstatechange", listener); + return () => peer.removeEventListener("icegatheringstatechange", listener); + }, + onConnectionStateChange: (listener) => { + const onStateChange = () => listener(peer.connectionState); + peer.addEventListener("connectionstatechange", onStateChange); + return () => peer.removeEventListener("connectionstatechange", onStateChange); + }, + close: () => peer.close(), + }; +} + +const webRtcClientPlatform = + typeof RTCPeerConnection === "undefined" + ? null + : makeClientWebRtcPeerFactory({ + createPeerConnection: createWebRtcPeerConnection, + randomBytes: (size) => + Effect.try({ + try: () => globalThis.crypto.getRandomValues(new Uint8Array(size)), + catch: (cause) => new WebRtcPeerError({ stage: "create", cause }), + }), + }); + function currentNetworkStatus(): "unknown" | "offline" | "online" { if (typeof navigator === "undefined") { return "unknown"; @@ -279,6 +378,7 @@ const capabilitiesLayer = Layer.effectContext( Context.add(RelayDeviceIdentity, identity), Context.add(ClientPresentation, presentation), Context.add(SshEnvironmentGateway, ssh), + Context.add(WebRtcClientPlatform, webRtcClientPlatform), ); }), ); diff --git a/docs/internals/websocket-webrtc-upgrade.md b/docs/internals/websocket-webrtc-upgrade.md new file mode 100644 index 000000000..099bbf50b --- /dev/null +++ b/docs/internals/websocket-webrtc-upgrade.md @@ -0,0 +1,47 @@ +# WebSocket WebRTC upgrade + +The `packages/websocket-webrtc` workspace wraps an Effect `Socket.Socket`. Callers still create one +RPC client or server and see one ordered socket. The wrapper keeps the authenticated WebSocket open +for the full session and uses a reliable, ordered WebRTC DataChannel when both peers finish +negotiation. + +The project integrations only provide platform adapters: + +- `client.ts` owns browser and React Native peer negotiation. +- `server.ts` owns offer handling, one-time DataChannel binding, and cutover. +- `socket.ts` owns framing, ordering, acknowledgements, replay, and fallback. +- `werift.ts` is the optional Node server adapter. The peer interfaces can accept another adapter + later without changing the framing protocol. + +## Capability negotiation + +The client adds `__t3_wsrtc=1.` to the existing WebSocket URL. An older server ignores the +query parameter and continues with ordinary WebSocket messages. A supporting server waits until the +upgrade request has passed normal authentication, then sends a hidden `hello` control message tied +to that nonce. An older client never adds the marker, so a supporting server leaves its socket +untouched. + +After `hello`, both peers cross a `frame-start` acknowledgement barrier before sending framed +application traffic. This keeps raw pre-negotiation messages separate from replayable messages. + +The WebRTC attempt then follows this sequence: + +1. The client sends an SDP offer over WebSocket. +2. The server returns an answer and a one-time binding token over WebSocket. +3. The client presents the token over the DataChannel and receives `bind-ack` there. +4. The client requests cutover over WebSocket. +5. The server selects the DataChannel and acknowledges cutover over WebSocket. + +## Delivery rules + +Each direction assigns a monotonic `uint64` sequence to application messages. DataChannel messages +use 16 KiB fragments. The receiver reassembles and delivers complete messages in sequence order, +then sends a cumulative acknowledgement over WebSocket. + +The sender retains at most 16 MiB of unacknowledged frames. If the DataChannel closes, errors, or +backs up past its limit, the wrapper selects WebSocket and replays retained frames there. Duplicate +fragments are safe because the receiver keys them by message sequence and fragment index. WebSocket +close still ends the whole logical connection. + +There is no NACK in version 1. Both physical transports are reliable, so cumulative ACK and replay +cover the path-switch case without another recovery mechanism. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 01600af46..e1e2bf02d 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -155,6 +155,7 @@ "dependencies": { "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/websocket-webrtc": "workspace:*", "effect": "catalog:" }, "devDependencies": { diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 9625effa4..a8c3d48f4 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -1,4 +1,7 @@ import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import { makeClientLogicalSocket } from "@t3tools/websocket-webrtc/client"; +import { WebRtcClientPlatform } from "@t3tools/websocket-webrtc/peer"; +import { prepareUpgradeUrl } from "@t3tools/websocket-webrtc/wire"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -67,6 +70,7 @@ function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionA export const make = Effect.gen(function* () { const webSocketConstructor = yield* Socket.WebSocketConstructor; + const webRtcPlatform = yield* WebRtcClientPlatform; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { yield* Effect.annotateCurrentSpan({ @@ -92,9 +96,42 @@ export const make = Effect.gen(function* () { Effect.asVoid, ), }); - const socketLayer = Socket.layerWebSocket(connection.socketUrl, { - openTimeout: SOCKET_OPEN_TIMEOUT, - }).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); + const preparedUpgrade = + webRtcPlatform === null + ? null + : yield* prepareUpgradeUrl(connection.socketUrl, webRtcPlatform).pipe( + Effect.catchTags({ + WebRtcPeerError: (error) => + Effect.logDebug("Could not prepare WebRTC upgrade capability.", { error }).pipe( + Effect.as(null), + ), + WebRtcWireError: (error) => + Effect.logDebug("Could not mark the WebSocket URL for WebRTC upgrade.", { + error, + }).pipe(Effect.as(null)), + }), + ); + const rawSocketLayer = Socket.layerWebSocket( + preparedUpgrade === null ? connection.socketUrl : preparedUpgrade.url, + { + openTimeout: SOCKET_OPEN_TIMEOUT, + }, + ).pipe(Layer.provide(Layer.succeed(Socket.WebSocketConstructor, webSocketConstructor))); + const socketLayer = + preparedUpgrade === null || webRtcPlatform === null + ? rawSocketLayer + : Layer.effect( + Socket.Socket, + Socket.Socket.pipe( + Effect.map((socket) => + makeClientLogicalSocket({ + socket, + nonce: preparedUpgrade.nonce, + peerFactory: webRtcPlatform, + }), + ), + ), + ).pipe(Layer.provide(rawSocketLayer)); const protocolLayer = Layer.effect( RpcClient.Protocol, RpcClient.makeProtocolSocket({ diff --git a/packages/websocket-webrtc/package.json b/packages/websocket-webrtc/package.json new file mode 100644 index 000000000..1fb470869 --- /dev/null +++ b/packages/websocket-webrtc/package.json @@ -0,0 +1,44 @@ +{ + "name": "@t3tools/websocket-webrtc", + "private": true, + "type": "module", + "exports": { + "./client": { + "types": "./src/client.ts", + "import": "./src/client.ts" + }, + "./effect-http": { + "types": "./src/effectHttp.ts", + "import": "./src/effectHttp.ts" + }, + "./peer": { + "types": "./src/peer.ts", + "import": "./src/peer.ts" + }, + "./server": { + "types": "./src/server.ts", + "import": "./src/server.ts" + }, + "./werift": { + "types": "./src/werift.ts", + "import": "./src/werift.ts" + }, + "./wire": { + "types": "./src/wire.ts", + "import": "./src/wire.ts" + } + }, + "scripts": { + "typecheck": "tsgo --noEmit" + }, + "dependencies": { + "effect": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "vite-plus": "catalog:" + }, + "optionalDependencies": { + "werift": "^0.24.4" + } +} diff --git a/packages/websocket-webrtc/src/client.ts b/packages/websocket-webrtc/src/client.ts new file mode 100644 index 000000000..4b0526d81 --- /dev/null +++ b/packages/websocket-webrtc/src/client.ts @@ -0,0 +1,414 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type * as Socket from "effect/unstable/socket/Socket"; + +import { + type ClientWebRtcPeerFactory, + type WebRtcDataChannelPort, + type WebRtcIceServer, + WebRtcPeerError, +} from "./peer.ts"; +import { makeLogicalSocket, type LogicalSocketSession } from "./socket.ts"; +import { DATA_CHANNEL_LABEL, type ControlMessage, wireIceServers } from "./wire.ts"; + +const NEGOTIATION_TIMEOUT = "15 seconds"; +const RETRY_DELAYS = ["1 second", "2 seconds", "5 seconds", "10 seconds", "30 seconds"] as const; +const MAX_RETRY_DELAY = "30 seconds"; + +interface NegotiatedAnswer { + readonly sdp: string; + readonly bindingToken: string; +} + +interface ClientAttemptState { + readonly attemptId: string; + readonly answer: Deferred.Deferred; + readonly bindAcknowledged: Deferred.Deferred; + readonly cutoverAcknowledged: Deferred.Deferred; + readonly stopped: Deferred.Deferred; +} + +export class WebRtcNegotiationError extends Schema.TaggedErrorClass()( + "WebRtcNegotiationError", + { + attemptId: Schema.String, + reason: Schema.Literals([ + "aborted", + "answer-timeout", + "bind-timeout", + "connection-timeout", + "cutover-timeout", + "offer-timeout", + ]), + }, +) { + override get message(): string { + return `WebRTC negotiation failed: ${this.reason}.`; + } +} + +export interface WebRtcSessionDescription { + readonly type: "offer" | "answer"; + readonly sdp: string; +} + +export type WebRtcPeerConnectionState = + | "new" + | "connecting" + | "connected" + | "disconnected" + | "failed" + | "closed"; + +export interface PlatformWebRtcPeerConnection { + readonly createDataChannel: (label: string) => WebRtcDataChannelPort; + readonly createOffer: () => Promise; + readonly setLocalDescription: (description: WebRtcSessionDescription) => Promise; + readonly localDescription: () => WebRtcSessionDescription | null; + readonly setRemoteDescription: (description: WebRtcSessionDescription) => Promise; + readonly iceGatheringState: () => "new" | "gathering" | "complete"; + readonly onIceGatheringStateChange: (listener: () => void) => () => void; + readonly onConnectionStateChange: ( + listener: (state: WebRtcPeerConnectionState) => void, + ) => () => void; + readonly close: () => void; +} + +function awaitNegotiationStep( + effect: Effect.Effect, + attemptId: string, + reason: + | "answer-timeout" + | "bind-timeout" + | "connection-timeout" + | "cutover-timeout" + | "offer-timeout", +): Effect.Effect { + return effect.pipe( + Effect.timeoutOption(NEGOTIATION_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(new WebRtcNegotiationError({ attemptId, reason })), + onSome: Effect.succeed, + }), + ), + ); +} + +const awaitDataChannelOpen = Effect.fn("WebRtcClient.awaitDataChannelOpen")(function* ( + port: WebRtcDataChannelPort, +) { + if (port.isOpen()) { + return; + } + const opened = yield* Deferred.make(); + const removeOpen = port.onOpen(() => { + Deferred.doneUnsafe(opened, Effect.void); + }); + const removeClose = port.onClose(() => { + Deferred.doneUnsafe( + opened, + Effect.fail( + new WebRtcPeerError({ + stage: "data-channel", + cause: new Error("WebRTC DataChannel closed before opening."), + }), + ), + ); + }); + const removeError = port.onError((cause) => { + Deferred.doneUnsafe(opened, Effect.fail(new WebRtcPeerError({ stage: "data-channel", cause }))); + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + removeOpen(); + removeClose(); + removeError(); + }), + ); + yield* Deferred.await(opened); +}); + +function makeClientDriver(session: LogicalSocketSession, peerFactory: ClientWebRtcPeerFactory) { + return Effect.gen(function* () { + let currentAttempt: ClientAttemptState | null = null; + let framingStarted = false; + + const stopAttempt = (attemptId: string, reason: "aborted") => + Effect.sync(() => { + if (currentAttempt?.attemptId !== attemptId) { + return; + } + Deferred.doneUnsafe( + currentAttempt.stopped, + Effect.fail(new WebRtcNegotiationError({ attemptId, reason })), + ); + }); + + const runAttempt = Effect.fn("WebRtcClient.runAttempt")(function* ( + iceServers: ReadonlyArray, + ) { + const attemptId = Encoding.encodeBase64Url(yield* peerFactory.randomBytes(16)); + const answer = yield* Deferred.make(); + const bindAcknowledged = yield* Deferred.make(); + const cutoverAcknowledged = yield* Deferred.make(); + const stopped = yield* Deferred.make(); + const attempt: ClientAttemptState = { + attemptId, + answer, + bindAcknowledged, + cutoverAcknowledged, + stopped, + }; + currentAttempt = attempt; + + const peer = yield* peerFactory.create(iceServers); + if (peer.dataChannel.label !== DATA_CHANNEL_LABEL || !peer.dataChannel.ordered) { + return yield* new WebRtcPeerError({ + stage: "data-channel", + cause: new Error("WebRTC peer created an incompatible DataChannel."), + }); + } + yield* session.attachDataChannel(attemptId, peer.dataChannel); + const offerSdp = yield* awaitNegotiationStep(peer.createOffer, attemptId, "offer-timeout"); + yield* session.sendControl({ kind: "offer", attemptId, sdp: offerSdp }); + const negotiated = yield* awaitNegotiationStep( + Deferred.await(answer), + attemptId, + "answer-timeout", + ).pipe(Effect.raceFirst(Deferred.await(stopped)), Effect.raceFirst(peer.closed)); + yield* awaitNegotiationStep( + peer.acceptAnswer(negotiated.sdp), + attemptId, + "connection-timeout", + ); + yield* awaitNegotiationStep( + awaitDataChannelOpen(peer.dataChannel), + attemptId, + "connection-timeout", + ).pipe(Effect.raceFirst(Deferred.await(stopped)), Effect.raceFirst(peer.closed)); + yield* session.sendRtcControl(attemptId, { + kind: "bind", + attemptId, + bindingToken: negotiated.bindingToken, + }); + yield* awaitNegotiationStep(Deferred.await(bindAcknowledged), attemptId, "bind-timeout").pipe( + Effect.raceFirst(Deferred.await(stopped)), + Effect.raceFirst(peer.closed), + ); + yield* session.sendControl({ kind: "cutover", attemptId }); + yield* awaitNegotiationStep( + Deferred.await(cutoverAcknowledged), + attemptId, + "cutover-timeout", + ).pipe(Effect.raceFirst(Deferred.await(stopped)), Effect.raceFirst(peer.closed)); + yield* session.selectDataChannel(attemptId); + return yield* Effect.raceFirst(Deferred.await(stopped), peer.closed); + }); + + const runAttempts = Effect.gen(function* () { + const iceServers = yield* session.framingReady; + let retryIndex = 0; + while (true) { + yield* Effect.scoped(runAttempt(iceServers)).pipe( + Effect.catchTags({ + SocketError: (error) => + Effect.logDebug("WebRTC upgrade attempt hit a socket error.", { error }), + WebRtcNegotiationError: (error) => + Effect.logDebug("WebRTC upgrade attempt did not complete.", { error }), + WebRtcPeerError: (error) => Effect.logDebug("WebRTC upgrade peer failed.", { error }), + }), + Effect.ensuring( + Effect.gen(function* () { + const attempt = currentAttempt; + currentAttempt = null; + if (attempt !== null) { + yield* session + .fallbackToWebSocket(attempt.attemptId) + .pipe( + Effect.catchTag("SocketError", (error) => + Effect.logDebug("Could not replay WebRTC traffic over WebSocket.", { error }), + ), + ); + yield* session.closeDataChannel(attempt.attemptId); + yield* session + .sendControl({ kind: "abort", attemptId: attempt.attemptId }) + .pipe( + Effect.catchTag("SocketError", (error) => + Effect.logDebug("Could not abort the WebRTC upgrade attempt.", { error }), + ), + ); + } + }), + ), + ); + yield* Effect.sleep(RETRY_DELAYS[retryIndex] ?? MAX_RETRY_DELAY); + retryIndex = Math.min(retryIndex + 1, RETRY_DELAYS.length - 1); + } + }); + yield* Effect.forkScoped(runAttempts); + + const onControl = (message: ControlMessage, source: "websocket" | "webrtc") => { + switch (message.kind) { + case "hello": + if (source !== "websocket" || framingStarted) { + return Effect.void; + } + framingStarted = true; + return session.beginClientFraming(wireIceServers(message.iceServers)); + case "frame-start-ack": + return source === "websocket" ? session.finishClientFraming : Effect.void; + case "answer": + if (source !== "websocket" || currentAttempt?.attemptId !== message.attemptId) { + return Effect.void; + } + return Deferred.succeed(currentAttempt.answer, { + sdp: message.sdp, + bindingToken: message.bindingToken, + }).pipe(Effect.asVoid); + case "bind-ack": + if (source !== "webrtc" || currentAttempt?.attemptId !== message.attemptId) { + return Effect.void; + } + return Deferred.succeed(currentAttempt.bindAcknowledged, undefined).pipe(Effect.asVoid); + case "cutover-ack": + if (source !== "websocket" || currentAttempt?.attemptId !== message.attemptId) { + return Effect.void; + } + return Deferred.succeed(currentAttempt.cutoverAcknowledged, undefined).pipe( + Effect.asVoid, + ); + case "abort": + return source === "websocket" ? stopAttempt(message.attemptId, "aborted") : Effect.void; + case "ack": + case "bind": + case "cutover": + case "fallback": + case "frame-start": + case "hello-ack": + case "offer": + return Effect.void; + } + }; + + return { + onOpen: Effect.void, + onControl, + onRtcClosed: (attemptId: string) => stopAttempt(attemptId, "aborted"), + close: Effect.void, + }; + }); +} + +export function makeClientLogicalSocket(options: { + readonly socket: Socket.Socket; + readonly nonce: string; + readonly peerFactory: ClientWebRtcPeerFactory; +}): Socket.Socket { + return makeLogicalSocket({ + socket: options.socket, + nonce: options.nonce, + makeDriver: (session) => makeClientDriver(session, options.peerFactory), + }); +} + +function isTerminalConnectionState(state: WebRtcPeerConnectionState): boolean { + return state === "failed" || state === "closed"; +} + +export function makeClientWebRtcPeerFactory(options: { + readonly createPeerConnection: ( + iceServers: ReadonlyArray, + ) => PlatformWebRtcPeerConnection; + readonly randomBytes: (size: number) => Effect.Effect; +}): ClientWebRtcPeerFactory { + return { + randomBytes: options.randomBytes, + create: Effect.fn("WebRtcClientPeerFactory.create")(function* (iceServers) { + const peer = yield* Effect.try({ + try: () => options.createPeerConnection(iceServers), + catch: (cause) => new WebRtcPeerError({ stage: "create", cause }), + }); + const gathered = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const removeGatheringListener = peer.onIceGatheringStateChange(() => { + if (peer.iceGatheringState() === "complete") { + Deferred.doneUnsafe(gathered, Effect.void); + } + }); + const removeConnectionListener = peer.onConnectionStateChange((state) => { + if (isTerminalConnectionState(state)) { + Deferred.doneUnsafe( + closed, + Effect.fail( + new WebRtcPeerError({ + stage: "connection", + cause: new Error(`WebRTC peer entered ${state} state.`), + }), + ), + ); + } + }); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + removeGatheringListener(); + removeConnectionListener(); + peer.close(); + }), + ); + const dataChannel = yield* Effect.try({ + try: () => peer.createDataChannel(DATA_CHANNEL_LABEL), + catch: (cause) => new WebRtcPeerError({ stage: "data-channel", cause }), + }); + + const createOffer = Effect.gen(function* () { + const offer = yield* Effect.tryPromise({ + try: () => peer.createOffer(), + catch: (cause) => new WebRtcPeerError({ stage: "offer", cause }), + }); + if (offer === null) { + return yield* new WebRtcPeerError({ + stage: "offer", + cause: new Error("WebRTC peer did not produce an offer."), + }); + } + yield* Effect.tryPromise({ + try: () => peer.setLocalDescription(offer), + catch: (cause) => new WebRtcPeerError({ stage: "offer", cause }), + }); + if (peer.iceGatheringState() === "complete") { + Deferred.doneUnsafe(gathered, Effect.void); + } + yield* Deferred.await(gathered); + const description = peer.localDescription(); + if (description === null || description.type !== "offer") { + return yield* new WebRtcPeerError({ + stage: "ice-gathering", + cause: new Error("WebRTC peer did not produce a complete offer."), + }); + } + return description.sdp; + }).pipe(Effect.raceFirst(Deferred.await(closed))); + + const acceptAnswer = Effect.fn("WebRtcClientPeer.acceptAnswer")(function* ( + answerSdp: string, + ) { + yield* Effect.tryPromise({ + try: () => peer.setRemoteDescription({ type: "answer", sdp: answerSdp }), + catch: (cause) => new WebRtcPeerError({ stage: "answer", cause }), + }); + }); + + return { + dataChannel, + createOffer, + acceptAnswer, + closed: Deferred.await(closed), + close: Effect.sync(() => peer.close()), + }; + }), + }; +} diff --git a/packages/websocket-webrtc/src/effectHttp.ts b/packages/websocket-webrtc/src/effectHttp.ts new file mode 100644 index 000000000..64bab309a --- /dev/null +++ b/packages/websocket-webrtc/src/effectHttp.ts @@ -0,0 +1,22 @@ +import * as Effect from "effect/Effect"; +import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import type * as Socket from "effect/unstable/socket/Socket"; + +export function mapSocketUpgrade( + request: HttpServerRequest.HttpServerRequest, + mapSocket: (socket: Socket.Socket) => Socket.Socket, +): HttpServerRequest.HttpServerRequest { + const upgrade = request.upgrade.pipe(Effect.map(mapSocket)); + return new Proxy(request, { + get(target, property, receiver) { + if (property === "upgrade") { + return upgrade; + } + if (property === "modify") { + return (options: Parameters[0]) => + mapSocketUpgrade(target.modify(options), mapSocket); + } + return Reflect.get(target, property, receiver); + }, + }); +} diff --git a/packages/websocket-webrtc/src/peer.ts b/packages/websocket-webrtc/src/peer.ts new file mode 100644 index 000000000..87149e36c --- /dev/null +++ b/packages/websocket-webrtc/src/peer.ts @@ -0,0 +1,81 @@ +import * as Context from "effect/Context"; +import type * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +export interface WebRtcIceServer { + readonly urls: ReadonlyArray; + readonly username?: string; + readonly credential?: string; +} + +export type WebRtcTransportKind = "websocket" | "webrtc"; + +export interface WebRtcDataChannelPort { + readonly label: string; + readonly ordered: boolean; + readonly isOpen: () => boolean; + readonly bufferedAmount: () => number; + readonly send: (data: Uint8Array) => void; + readonly close: () => void; + readonly onOpen: (listener: () => void) => () => void; + readonly onMessage: (listener: (data: Uint8Array) => void) => () => void; + readonly onClose: (listener: () => void) => () => void; + readonly onError: (listener: (cause: unknown) => void) => () => void; +} + +export const WebRtcPeerErrorStage = Schema.Literals([ + "create", + "offer", + "answer", + "ice-gathering", + "connection", + "data-channel", + "signaling", +]); +export type WebRtcPeerErrorStage = typeof WebRtcPeerErrorStage.Type; + +export class WebRtcPeerError extends Schema.TaggedErrorClass()("WebRtcPeerError", { + stage: WebRtcPeerErrorStage, + cause: Schema.Defect(), +}) { + override get message(): string { + return `WebRTC peer failed during ${this.stage}.`; + } +} + +export interface ClientWebRtcPeer { + readonly dataChannel: WebRtcDataChannelPort; + readonly createOffer: Effect.Effect; + readonly acceptAnswer: (answerSdp: string) => Effect.Effect; + readonly closed: Effect.Effect; + readonly close: Effect.Effect; +} + +export interface ClientWebRtcPeerFactory { + readonly create: ( + iceServers: ReadonlyArray, + ) => Effect.Effect; + readonly randomBytes: (size: number) => Effect.Effect; +} + +export class WebRtcClientPlatform extends Context.Reference( + "@t3tools/websocket-webrtc/WebRtcClientPlatform", + { + defaultValue: () => null, + }, +) {} + +export interface ServerWebRtcPeer { + readonly acceptOffer: (offerSdp: string) => Effect.Effect; + readonly dataChannel: Effect.Effect; + readonly closed: Effect.Effect; + readonly close: Effect.Effect; +} + +export interface ServerWebRtcPeerFactory { + readonly create: ( + iceServers: ReadonlyArray, + ) => Effect.Effect; + readonly randomBytes: (size: number) => Effect.Effect; +} diff --git a/packages/websocket-webrtc/src/server.ts b/packages/websocket-webrtc/src/server.ts new file mode 100644 index 000000000..b9b3c0061 --- /dev/null +++ b/packages/websocket-webrtc/src/server.ts @@ -0,0 +1,285 @@ +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Exit from "effect/Exit"; +import * as Option from "effect/Option"; +import * as Scope from "effect/Scope"; +import type * as Socket from "effect/unstable/socket/Socket"; + +import { type ServerWebRtcPeerFactory, type WebRtcIceServer, WebRtcPeerError } from "./peer.ts"; +import { makeLogicalSocket, type LogicalSocketSession } from "./socket.ts"; +import { + DATA_CHANNEL_LABEL, + type ControlMessage, + PROTOCOL_VERSION, + wireIceServers, +} from "./wire.ts"; + +const SERVER_NEGOTIATION_TIMEOUT = "15 seconds"; + +interface ServerAttemptState { + readonly attemptId: string; + readonly scope: Scope.Closeable; + bindingToken: string | null; + bound: boolean; +} + +function makeServerDriver(options: { + readonly session: LogicalSocketSession; + readonly peerFactory: ServerWebRtcPeerFactory; + readonly iceServers: ReadonlyArray; +}) { + return Effect.gen(function* () { + const driverScope = yield* Scope.Scope; + let currentAttempt: ServerAttemptState | null = null; + let clientAccepted = false; + let framingComplete = false; + + const disposeAttempt = (attempt: ServerAttemptState) => + Effect.gen(function* () { + if (currentAttempt === attempt) { + currentAttempt = null; + } + yield* options.session.closeDataChannel(attempt.attemptId); + yield* Scope.close(attempt.scope, Exit.void); + }); + + const failAttempt = ( + attempt: ServerAttemptState, + error: Error | Socket.SocketError | WebRtcPeerError, + ) => + disposeAttempt(attempt).pipe( + Effect.andThen( + options.session.sendControl({ kind: "abort", attemptId: attempt.attemptId }), + ), + Effect.catchTag("SocketError", (socketError) => + Effect.logDebug("Could not report a failed WebRTC server attempt.", { socketError }), + ), + Effect.andThen(Effect.logDebug("WebRTC server attempt failed.", { error })), + ); + + const processOffer = Effect.fn("WebRtcServer.processOffer")(function* ( + attemptId: string, + offerSdp: string, + ) { + const previousAttempt = currentAttempt; + if (previousAttempt !== null) { + yield* options.session.fallbackToWebSocket(previousAttempt.attemptId); + yield* disposeAttempt(previousAttempt); + } + + const attemptScope = yield* Scope.make(); + const attempt: ServerAttemptState = { + attemptId, + scope: attemptScope, + bindingToken: null, + bound: false, + }; + currentAttempt = attempt; + const peer = yield* options.peerFactory + .create(options.iceServers) + .pipe(Scope.provide(attemptScope)); + const bindingToken = Encoding.encodeBase64Url(yield* options.peerFactory.randomBytes(32)); + const answerSdp = yield* peer.acceptOffer(offerSdp).pipe( + Effect.timeoutOption(SERVER_NEGOTIATION_TIMEOUT), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail( + new WebRtcPeerError({ + stage: "answer", + cause: new Error("WebRTC server answer timed out."), + }), + ), + onSome: Effect.succeed, + }), + ), + ); + if (currentAttempt !== attempt) { + yield* Scope.close(attemptScope, Exit.void); + return; + } + attempt.bindingToken = bindingToken; + yield* options.session.sendControl({ + kind: "answer", + attemptId, + sdp: answerSdp, + bindingToken, + }); + + yield* peer.dataChannel.pipe( + Effect.flatMap((port) => { + if (port.label !== DATA_CHANNEL_LABEL || !port.ordered) { + return failAttempt( + attempt, + new Error("Client created an incompatible WebRTC DataChannel."), + ); + } + if (currentAttempt !== attempt) { + port.close(); + return Effect.void; + } + return options.session.attachDataChannel(attemptId, port); + }), + Effect.catchTags({ + SocketError: (error) => failAttempt(attempt, error), + WebRtcPeerError: (error) => failAttempt(attempt, error), + }), + Effect.forkIn(driverScope), + ); + + yield* peer.closed.pipe( + Effect.catchTag("WebRtcPeerError", (error) => { + if (currentAttempt !== attempt) { + return Effect.void; + } + return options.session.fallbackToWebSocket(attemptId).pipe( + Effect.andThen(disposeAttempt(attempt)), + Effect.andThen(options.session.sendControl({ kind: "fallback", attemptId })), + Effect.catchTag("SocketError", (socketError) => + Effect.logDebug("Could not report a closed WebRTC server peer.", { + error, + socketError, + }), + ), + ); + }), + Effect.forkIn(driverScope), + ); + }); + + const startOffer = (attemptId: string, offerSdp: string) => + processOffer(attemptId, offerSdp).pipe( + Effect.catchTags({ + SocketError: (error) => { + const attempt = currentAttempt; + return attempt?.attemptId === attemptId + ? failAttempt(attempt, error) + : Effect.logDebug("WebRTC server signaling failed.", { error }); + }, + WebRtcPeerError: (error) => { + const attempt = currentAttempt; + return attempt?.attemptId === attemptId + ? failAttempt(attempt, error) + : Effect.logDebug("WebRTC server peer setup failed.", { error }); + }, + }), + Effect.forkIn(driverScope), + Effect.asVoid, + ); + + const onControl = (message: ControlMessage, source: "websocket" | "webrtc") => { + switch (message.kind) { + case "hello-ack": + if (source === "websocket") { + clientAccepted = true; + } + return Effect.void; + case "frame-start": + if (source !== "websocket" || !clientAccepted || framingComplete) { + return Effect.void; + } + framingComplete = true; + return options.session.finishServerFraming; + case "offer": + return source === "websocket" && framingComplete + ? startOffer(message.attemptId, message.sdp) + : Effect.void; + case "bind": { + const attempt = currentAttempt; + if ( + source !== "webrtc" || + attempt?.attemptId !== message.attemptId || + attempt.bindingToken !== message.bindingToken || + attempt.bound + ) { + return Effect.void; + } + attempt.bound = true; + attempt.bindingToken = null; + return options.session.sendRtcControl(message.attemptId, { + kind: "bind-ack", + attemptId: message.attemptId, + }); + } + case "cutover": { + const attempt = currentAttempt; + if ( + source !== "websocket" || + attempt?.attemptId !== message.attemptId || + !attempt.bound + ) { + return Effect.void; + } + return options.session.selectDataChannel(message.attemptId).pipe( + Effect.andThen( + options.session.sendControl({ + kind: "cutover-ack", + attemptId: message.attemptId, + }), + ), + ); + } + case "abort": { + const attempt = currentAttempt; + return source === "websocket" && attempt?.attemptId === message.attemptId + ? disposeAttempt(attempt) + : Effect.void; + } + case "ack": + case "answer": + case "bind-ack": + case "cutover-ack": + case "fallback": + case "frame-start-ack": + case "hello": + return Effect.void; + } + }; + + return { + onOpen: options.session + .sendControl({ + kind: "hello", + version: PROTOCOL_VERSION, + iceServers: wireIceServers(options.iceServers), + }) + .pipe( + Effect.catchTag("SocketError", (error) => + Effect.logDebug("Could not advertise WebRTC upgrade support.", { error }), + ), + ), + onControl, + onRtcClosed: (attemptId: string) => { + const attempt = currentAttempt; + if (attempt?.attemptId !== attemptId) { + return Effect.void; + } + return disposeAttempt(attempt).pipe( + Effect.andThen(options.session.sendControl({ kind: "fallback", attemptId })), + ); + }, + close: Effect.suspend(() => + currentAttempt === null ? Effect.void : disposeAttempt(currentAttempt), + ), + }; + }); +} + +export function makeServerLogicalSocket(options: { + readonly socket: Socket.Socket; + readonly nonce: string; + readonly peerFactory: ServerWebRtcPeerFactory; + readonly iceServers?: ReadonlyArray; +}): Socket.Socket { + const iceServers = options.iceServers ?? []; + return makeLogicalSocket({ + socket: options.socket, + nonce: options.nonce, + makeDriver: (session) => + makeServerDriver({ + session, + peerFactory: options.peerFactory, + iceServers, + }), + }); +} diff --git a/packages/websocket-webrtc/src/socket.ts b/packages/websocket-webrtc/src/socket.ts new file mode 100644 index 000000000..1d95e06ce --- /dev/null +++ b/packages/websocket-webrtc/src/socket.ts @@ -0,0 +1,668 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as Socket from "effect/unstable/socket/Socket"; + +import type { WebRtcDataChannelPort, WebRtcIceServer, WebRtcTransportKind } from "./peer.ts"; +import { + type ApplicationFragment, + type ControlMessage, + decodeApplicationFrame, + decodeControl, + encodeApplicationFrames, + encodeControl, +} from "./wire.ts"; + +const MAX_BUFFERED_REPLAY_BYTES = 16 * 1024 * 1024; +const MAX_BUFFERED_RECEIVE_BYTES = 16 * 1024 * 1024; +const MAX_DATA_CHANNEL_BUFFERED_BYTES = 16 * 1024 * 1024; +const MAX_PENDING_MESSAGES = 1024; + +type ApplicationChunk = string | Uint8Array; +type ControlSource = "websocket" | "webrtc"; + +type InboundEvent = + | { readonly kind: "websocket"; readonly data: ApplicationChunk } + | { readonly kind: "webrtc"; readonly attemptId: string; readonly data: Uint8Array } + | { readonly kind: "webrtc-closed"; readonly attemptId: string }; + +interface FragmentAssembly { + readonly text: boolean; + readonly fragmentCount: number; + readonly fragments: Array; + receivedCount: number; + receivedBytes: number; +} + +interface CompleteMessage { + readonly text: boolean; + readonly payload: Uint8Array; +} + +interface ActiveDataChannel { + readonly attemptId: string; + readonly port: WebRtcDataChannelPort; + readonly removeListeners: () => void; +} + +export interface LogicalSocketSession { + readonly nonce: string; + readonly framingReady: Effect.Effect>; + readonly sendControl: (message: ControlMessage) => Effect.Effect; + readonly sendRtcControl: ( + attemptId: string, + message: ControlMessage, + ) => Effect.Effect; + readonly beginClientFraming: ( + iceServers: ReadonlyArray, + ) => Effect.Effect; + readonly finishClientFraming: Effect.Effect; + readonly finishServerFraming: Effect.Effect; + readonly attachDataChannel: ( + attemptId: string, + port: WebRtcDataChannelPort, + ) => Effect.Effect; + readonly selectDataChannel: (attemptId: string) => Effect.Effect; + readonly fallbackToWebSocket: (attemptId: string) => Effect.Effect; + readonly closeDataChannel: (attemptId: string) => Effect.Effect; +} + +export interface LogicalSocketDriver { + readonly onOpen: Effect.Effect; + readonly onControl: ( + message: ControlMessage, + source: ControlSource, + ) => Effect.Effect; + readonly onRtcClosed: (attemptId: string) => Effect.Effect; + readonly close: Effect.Effect; +} + +export interface MakeLogicalSocketOptions { + readonly socket: Socket.Socket; + readonly nonce: string; + readonly makeDriver: ( + session: LogicalSocketSession, + ) => Effect.Effect; + readonly onTransportChange?: (transport: WebRtcTransportKind) => void; +} + +function readFailure(cause: unknown): Socket.SocketError { + return new Socket.SocketError({ + reason: new Socket.SocketReadError({ cause }), + }); +} + +function writeFailure(cause: unknown): Socket.SocketError { + return new Socket.SocketError({ + reason: new Socket.SocketWriteError({ cause }), + }); +} + +function concatenateFragments(assembly: FragmentAssembly): Uint8Array { + const payload = new Uint8Array(assembly.receivedBytes); + let offset = 0; + for (const fragment of assembly.fragments) { + if (fragment === null) { + continue; + } + payload.set(fragment, offset); + offset += fragment.byteLength; + } + return payload; +} + +export function makeLogicalSocket(options: MakeLogicalSocketOptions): Socket.Socket { + const openLatch = Latch.makeUnsafe(false); + let writeApplication: + | ((chunk: ApplicationChunk | Socket.CloseEvent) => Effect.Effect) + | null = null; + + const writer = Effect.succeed((chunk: ApplicationChunk | Socket.CloseEvent) => { + const ownedChunk = chunk instanceof Uint8Array ? Uint8Array.from(chunk) : chunk; + return openLatch.whenOpen( + Effect.suspend(() => { + if (writeApplication === null) { + return Effect.fail(writeFailure(new Error("Logical WebSocket is not running."))); + } + return writeApplication(ownedChunk); + }), + ); + }); + + const runRaw = ( + handler: (data: ApplicationChunk) => Effect.Effect | void, + runOptions?: { readonly onOpen?: Effect.Effect | undefined }, + ): Effect.Effect => + Effect.scoped( + Effect.gen(function* () { + const rawWriter = yield* options.socket.writer; + const sendLock = yield* Semaphore.make(1); + const inbound = yield* Queue.unbounded(); + const framingReady = yield* Deferred.make>(); + const pendingApplication: Array = []; + const unacked = new Map>(); + const assemblies = new Map(); + const complete = new Map(); + let outboundMode: "raw" | "activating" | "framed" = "raw"; + let inboundFramed = false; + let route: WebRtcTransportKind = "websocket"; + let activeDataChannel: ActiveDataChannel | null = null; + let nextOutboundSequence = 0n; + let nextInboundSequence = 0n; + let replayBytes = 0; + let receiveBytes = 0; + let pendingApplicationBytes = 0; + let clientIceServers: ReadonlyArray | null = null; + + const rawWrite = (chunk: ApplicationChunk | Socket.CloseEvent) => rawWriter(chunk); + + const sendControl = (message: ControlMessage) => + sendLock.withPermits(1)(rawWrite(encodeControl(options.nonce, message))); + + const sendFramesOnWebSocket = (frames: ReadonlyArray) => + Effect.forEach(frames, rawWrite, { discard: true }); + + const replayUnacked = Effect.suspend(() => + Effect.forEach(unacked.values(), sendFramesOnWebSocket, { discard: true }), + ); + + const setTransport = (transport: WebRtcTransportKind) => + Effect.sync(() => { + if (route === transport) { + return; + } + route = transport; + options.onTransportChange?.(transport); + }); + + const fallbackLocked = Effect.fn("LogicalWebSocket.fallbackLocked")(function* ( + attemptId: string, + ) { + if (activeDataChannel?.attemptId !== attemptId) { + return; + } + activeDataChannel.removeListeners(); + activeDataChannel.port.close(); + activeDataChannel = null; + yield* setTransport("websocket"); + yield* replayUnacked; + }); + + const sendFramedLocked = Effect.fn("LogicalWebSocket.sendFramedLocked")(function* ( + chunk: ApplicationChunk, + ) { + const sequence = nextOutboundSequence; + const frames = yield* encodeApplicationFrames(options.nonce, sequence, chunk).pipe( + Effect.mapError(writeFailure), + ); + const frameBytes = frames.reduce((total, frame) => total + frame.byteLength, 0); + if ( + unacked.size >= MAX_PENDING_MESSAGES || + replayBytes + frameBytes > MAX_BUFFERED_REPLAY_BYTES + ) { + return yield* writeFailure( + new Error("Logical WebSocket replay buffer exceeded its limit."), + ); + } + nextOutboundSequence += 1n; + replayBytes += frameBytes; + unacked.set(sequence, frames); + const dataChannel = activeDataChannel; + if (route === "webrtc" && dataChannel !== null) { + if (dataChannel.port.bufferedAmount() > MAX_DATA_CHANNEL_BUFFERED_BYTES) { + yield* fallbackLocked(dataChannel.attemptId); + Queue.offerUnsafe(inbound, { + kind: "webrtc-closed", + attemptId: dataChannel.attemptId, + }); + return; + } + const sent = yield* Effect.try({ + try: () => { + for (const frame of frames) { + dataChannel.port.send(frame); + } + }, + catch: writeFailure, + }).pipe(Effect.option); + if (Option.isSome(sent)) { + return; + } + yield* fallbackLocked(dataChannel.attemptId); + Queue.offerUnsafe(inbound, { + kind: "webrtc-closed", + attemptId: dataChannel.attemptId, + }); + return; + } + yield* sendFramesOnWebSocket(frames); + }); + + const flushPendingLocked = Effect.forEach(pendingApplication, sendFramedLocked, { + discard: true, + }).pipe( + Effect.ensuring( + Effect.sync(() => { + pendingApplication.length = 0; + }), + ), + ); + + const sendApplication = (chunk: ApplicationChunk | Socket.CloseEvent) => + sendLock.withPermits(1)( + Effect.suspend(() => { + if (Socket.isCloseEvent(chunk)) { + if (activeDataChannel !== null) { + activeDataChannel.removeListeners(); + activeDataChannel.port.close(); + activeDataChannel = null; + } + return rawWrite(chunk); + } + switch (outboundMode) { + case "raw": + return rawWrite(chunk); + case "activating": { + pendingApplicationBytes += + typeof chunk === "string" + ? new TextEncoder().encode(chunk).byteLength + : chunk.byteLength; + if ( + pendingApplication.length >= MAX_PENDING_MESSAGES || + pendingApplicationBytes > MAX_BUFFERED_REPLAY_BYTES + ) { + return Effect.fail( + writeFailure( + new Error("Logical WebSocket pending buffer exceeded its limit."), + ), + ); + } + pendingApplication.push(chunk); + return Effect.void; + } + case "framed": + return sendFramedLocked(chunk); + } + }), + ); + writeApplication = sendApplication; + + const beginClientFraming = (iceServers: ReadonlyArray) => + sendLock.withPermits(1)( + Effect.gen(function* () { + if (outboundMode !== "raw") { + return; + } + outboundMode = "activating"; + clientIceServers = iceServers; + yield* rawWrite( + encodeControl(options.nonce, { + kind: "hello-ack", + version: 1, + }), + ); + yield* rawWrite( + encodeControl(options.nonce, { + kind: "frame-start", + version: 1, + }), + ); + }), + ); + + const finishClientFraming = sendLock.withPermits(1)( + Effect.gen(function* () { + if (outboundMode !== "activating") { + return; + } + const iceServers = clientIceServers; + if (iceServers === null) { + return yield* writeFailure( + new Error("Logical WebSocket framing started without ICE server configuration."), + ); + } + inboundFramed = true; + outboundMode = "framed"; + yield* flushPendingLocked; + pendingApplicationBytes = 0; + yield* Deferred.succeed(framingReady, iceServers); + }), + ); + + const finishServerFraming = sendLock.withPermits(1)( + Effect.gen(function* () { + if (outboundMode !== "raw") { + return; + } + inboundFramed = true; + outboundMode = "framed"; + yield* rawWrite( + encodeControl(options.nonce, { + kind: "frame-start-ack", + version: 1, + }), + ); + yield* Deferred.succeed(framingReady, []); + }), + ); + + const attachDataChannel = (attemptId: string, port: WebRtcDataChannelPort) => + sendLock.withPermits(1)( + Effect.gen(function* () { + if (!port.ordered) { + port.close(); + return yield* writeFailure( + new Error("Logical WebSocket requires an ordered DataChannel."), + ); + } + if (activeDataChannel !== null) { + yield* fallbackLocked(activeDataChannel.attemptId); + } + const removeMessage = port.onMessage((data) => { + Queue.offerUnsafe(inbound, { kind: "webrtc", attemptId, data }); + }); + const notifyClosed = () => { + Queue.offerUnsafe(inbound, { kind: "webrtc-closed", attemptId }); + }; + const removeClose = port.onClose(notifyClosed); + const removeError = port.onError(notifyClosed); + activeDataChannel = { + attemptId, + port, + removeListeners: () => { + removeMessage(); + removeClose(); + removeError(); + }, + }; + }), + ); + + const selectDataChannel = (attemptId: string) => + sendLock.withPermits(1)( + Effect.gen(function* () { + if (activeDataChannel?.attemptId !== attemptId || !activeDataChannel.port.isOpen()) { + return; + } + yield* setTransport("webrtc"); + }), + ); + + const fallbackToWebSocket = (attemptId: string) => + sendLock.withPermits(1)(fallbackLocked(attemptId)); + + const closeDataChannel = (attemptId: string) => + Effect.sync(() => { + if (activeDataChannel?.attemptId !== attemptId) { + return; + } + activeDataChannel.removeListeners(); + activeDataChannel.port.close(); + activeDataChannel = null; + }); + + const sendRtcControl = (attemptId: string, message: ControlMessage) => + Effect.suspend(() => { + const dataChannel = activeDataChannel; + if (dataChannel?.attemptId !== attemptId) { + return Effect.fail( + writeFailure(new Error("WebRTC DataChannel attempt is no longer active.")), + ); + } + return Effect.try({ + try: () => + dataChannel.port.send( + new TextEncoder().encode(encodeControl(options.nonce, message)), + ), + catch: writeFailure, + }); + }); + + const session: LogicalSocketSession = { + nonce: options.nonce, + framingReady: Deferred.await(framingReady), + sendControl, + sendRtcControl, + beginClientFraming, + finishClientFraming, + finishServerFraming, + attachDataChannel, + selectDataChannel, + fallbackToWebSocket, + closeDataChannel, + }; + const driver = yield* options.makeDriver(session); + + const acknowledge = (nextSequence: bigint) => + sendControl({ kind: "ack", nextSequence: nextSequence.toString() }); + + const deliverComplete = Effect.fn("LogicalWebSocket.deliverComplete")(function* () { + while (true) { + const message = complete.get(nextInboundSequence); + if (message === undefined) { + return; + } + complete.delete(nextInboundSequence); + receiveBytes -= message.payload.byteLength; + const delivered = message.text + ? new TextDecoder().decode(message.payload) + : message.payload; + const result = handler(delivered); + if (Effect.isEffect(result)) { + yield* result; + } + nextInboundSequence += 1n; + } + }); + + const receiveFragment = Effect.fn("LogicalWebSocket.receiveFragment")(function* ( + fragment: ApplicationFragment, + ) { + if (fragment.sequence < nextInboundSequence) { + yield* acknowledge(nextInboundSequence); + return; + } + if (complete.has(fragment.sequence)) { + yield* acknowledge(nextInboundSequence); + return; + } + let assembly = assemblies.get(fragment.sequence); + if (assembly === undefined) { + if (assemblies.size + complete.size >= MAX_PENDING_MESSAGES) { + return yield* readFailure( + new Error("Logical WebSocket has too many pending messages."), + ); + } + assembly = { + text: fragment.text, + fragmentCount: fragment.fragmentCount, + fragments: Array.from({ length: fragment.fragmentCount }, () => null), + receivedCount: 0, + receivedBytes: 0, + }; + assemblies.set(fragment.sequence, assembly); + } else if ( + assembly.text !== fragment.text || + assembly.fragmentCount !== fragment.fragmentCount + ) { + return yield* readFailure( + new Error("WebRTC message fragments disagree on their metadata."), + ); + } + if (assembly.fragments[fragment.fragmentIndex] !== null) { + return; + } + if (receiveBytes + fragment.payload.byteLength > MAX_BUFFERED_RECEIVE_BYTES) { + return yield* readFailure( + new Error("Logical WebSocket receive buffer exceeded its byte limit."), + ); + } + assembly.fragments[fragment.fragmentIndex] = fragment.payload; + assembly.receivedCount += 1; + assembly.receivedBytes += fragment.payload.byteLength; + receiveBytes += fragment.payload.byteLength; + if (assembly.receivedCount !== assembly.fragmentCount) { + return; + } + assemblies.delete(fragment.sequence); + complete.set(fragment.sequence, { + text: assembly.text, + payload: concatenateFragments(assembly), + }); + yield* deliverComplete(); + yield* acknowledge(nextInboundSequence); + }); + + const removeAcknowledged = (nextSequenceText: string) => + sendLock.withPermits(1)( + Effect.try({ + try: () => BigInt(nextSequenceText), + catch: (cause) => readFailure(cause), + }).pipe( + Effect.flatMap((nextSequence) => + nextSequence < 0n || nextSequence > nextOutboundSequence + ? Effect.fail( + readFailure(new Error("Received an invalid WebRTC acknowledgement.")), + ) + : Effect.sync(() => { + for (const [sequence, frames] of unacked) { + if (sequence >= nextSequence) { + continue; + } + replayBytes -= frames.reduce((total, frame) => total + frame.byteLength, 0); + unacked.delete(sequence); + } + }), + ), + ), + ); + + const handleControl = Effect.fn("LogicalWebSocket.handleControl")(function* ( + message: ControlMessage, + source: ControlSource, + ) { + switch (message.kind) { + case "ack": + if (source !== "websocket") { + return; + } + yield* removeAcknowledged(message.nextSequence); + return; + case "fallback": + if (source !== "websocket") { + return; + } + yield* fallbackToWebSocket(message.attemptId); + yield* driver.onRtcClosed(message.attemptId); + return; + default: + yield* driver.onControl(message, source); + } + }); + + const decodeHiddenControl = (data: ApplicationChunk) => + decodeControl(options.nonce, data).pipe(Effect.mapError(readFailure)); + + const decodeHiddenData = (data: ApplicationChunk) => + typeof data === "string" + ? Effect.succeed(Option.none()) + : decodeApplicationFrame(options.nonce, data).pipe(Effect.mapError(readFailure)); + + const handleWebSocketData = Effect.fn("LogicalWebSocket.handleWebSocketData")(function* ( + data: ApplicationChunk, + ) { + const control = yield* decodeHiddenControl(data); + if (Option.isSome(control)) { + yield* handleControl(control.value, "websocket"); + return; + } + const fragment = yield* decodeHiddenData(data); + if (Option.isSome(fragment)) { + yield* receiveFragment(fragment.value); + return; + } + if (inboundFramed) { + return yield* readFailure( + new Error("Received an unframed message after framing was activated."), + ); + } + const result = handler(data); + if (Effect.isEffect(result)) { + yield* result; + } + }); + + const handleRtcData = Effect.fn("LogicalWebSocket.handleRtcData")(function* ( + attemptId: string, + data: Uint8Array, + ) { + if (activeDataChannel?.attemptId !== attemptId) { + return; + } + const control = yield* decodeHiddenControl(data); + if (Option.isSome(control)) { + yield* handleControl(control.value, "webrtc"); + return; + } + const fragment = yield* decodeHiddenData(data); + if (Option.isSome(fragment)) { + yield* receiveFragment(fragment.value); + } + }); + + const processInbound = Queue.take(inbound).pipe( + Effect.flatMap((event) => { + switch (event.kind) { + case "websocket": + return handleWebSocketData(event.data); + case "webrtc": + return handleRtcData(event.attemptId, event.data); + case "webrtc-closed": + return fallbackToWebSocket(event.attemptId).pipe( + Effect.andThen(driver.onRtcClosed(event.attemptId)), + ); + } + }), + Effect.forever, + ); + + yield* Effect.addFinalizer(() => + driver.close.pipe( + Effect.andThen( + Effect.sync(() => { + if (activeDataChannel !== null) { + activeDataChannel.removeListeners(); + activeDataChannel.port.close(); + activeDataChannel = null; + } + writeApplication = null; + openLatch.closeUnsafe(); + }), + ), + ), + ); + + const rawRun = options.socket.runRaw( + (data) => Queue.offer(inbound, { kind: "websocket", data }).pipe(Effect.asVoid), + { + onOpen: driver.onOpen.pipe( + Effect.andThen( + Effect.sync(() => { + openLatch.openUnsafe(); + }), + ), + Effect.andThen(runOptions?.onOpen ?? Effect.void), + ), + }, + ); + + return yield* Effect.raceFirst(rawRun, processInbound); + }), + ); + + return Socket.make({ runRaw, writer }); +} diff --git a/packages/websocket-webrtc/src/werift.ts b/packages/websocket-webrtc/src/werift.ts new file mode 100644 index 000000000..7f0ead4db --- /dev/null +++ b/packages/websocket-webrtc/src/werift.ts @@ -0,0 +1,221 @@ +import * as NodeBuffer from "node:buffer"; +import * as NodeCrypto from "node:crypto"; + +import type { RTCDataChannel, RTCPeerConnection } from "werift"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; + +import { + type ServerWebRtcPeer, + type ServerWebRtcPeerFactory, + type WebRtcDataChannelPort, + WebRtcPeerError, +} from "./peer.ts"; + +const EARLY_MESSAGE_LIMIT = 4; +const EARLY_MESSAGE_BYTES_LIMIT = 64 * 1024; +const MAX_MESSAGE_SIZE = 16 * 1024; + +type WeriftDataChannel = Pick< + RTCDataChannel, + | "label" + | "ordered" + | "readyState" + | "bufferedAmount" + | "send" + | "close" + | "stateChanged" + | "onMessage" + | "error" +>; + +function weriftDataChannelPort(channel: WeriftDataChannel): WebRtcDataChannelPort { + const earlyMessages: Array = []; + let earlyMessageBytes = 0; + let messageListener: ((data: Uint8Array) => void) | null = null; + let messageSubscriptionClosed = false; + const messageSubscription = channel.onMessage.subscribe((message) => { + const data = + typeof message === "string" ? new TextEncoder().encode(message) : Uint8Array.from(message); + if (messageListener !== null) { + messageListener(data); + return; + } + if ( + earlyMessages.length >= EARLY_MESSAGE_LIMIT || + earlyMessageBytes + data.byteLength > EARLY_MESSAGE_BYTES_LIMIT + ) { + channel.close(); + return; + } + earlyMessages.push(data); + earlyMessageBytes += data.byteLength; + }); + + const closeMessageSubscription = () => { + if (messageSubscriptionClosed) { + return; + } + messageSubscriptionClosed = true; + messageSubscription.unSubscribe(); + earlyMessages.length = 0; + earlyMessageBytes = 0; + messageListener = null; + }; + + return { + label: channel.label, + ordered: channel.ordered, + isOpen: () => channel.readyState === "open", + bufferedAmount: () => channel.bufferedAmount, + send: (data) => channel.send(NodeBuffer.Buffer.from(data)), + close: () => { + closeMessageSubscription(); + channel.close(); + }, + onOpen: (listener) => { + const subscription = channel.stateChanged.subscribe((state) => { + if (state === "open") { + listener(); + } + }); + return subscription.unSubscribe; + }, + onMessage: (listener) => { + if (messageListener !== null) { + channel.close(); + return () => undefined; + } + messageListener = listener; + for (const message of earlyMessages.splice(0)) { + listener(message); + } + earlyMessageBytes = 0; + return () => { + if (messageListener === listener) { + messageListener = null; + } + }; + }, + onClose: (listener) => { + const subscription = channel.stateChanged.subscribe((state) => { + if (state === "closed") { + closeMessageSubscription(); + listener(); + } + }); + return subscription.unSubscribe; + }, + onError: (listener) => { + const subscription = channel.error.subscribe(listener); + return subscription.unSubscribe; + }, + }; +} + +function makeWeriftPeer(connection: RTCPeerConnection): Effect.Effect { + return Effect.gen(function* () { + const closed = yield* Deferred.make(); + const dataChannels = yield* Queue.unbounded(); + let acceptedDataChannel = false; + const stateSubscription = connection.connectionStateChange.subscribe((state) => { + if (state !== "failed" && state !== "closed") { + return; + } + Deferred.doneUnsafe( + closed, + Effect.fail( + new WebRtcPeerError({ + stage: "connection", + cause: new Error(`WebRTC peer entered ${state} state.`), + }), + ), + ); + }); + const dataChannelSubscription = connection.onDataChannel.subscribe((channel) => { + if (acceptedDataChannel) { + channel.close(); + return; + } + acceptedDataChannel = true; + Queue.offerUnsafe(dataChannels, weriftDataChannelPort(channel)); + }); + + const acceptOffer = Effect.fn("WeriftServerPeer.acceptOffer")(function* (offerSdp: string) { + yield* Effect.tryPromise({ + try: async () => { + await connection.setRemoteDescription({ type: "offer", sdp: offerSdp }); + const answer = await connection.createAnswer(); + await connection.setLocalDescription(answer); + }, + catch: (cause) => new WebRtcPeerError({ stage: "offer", cause }), + }).pipe(Effect.raceFirst(Deferred.await(closed))); + const answer = connection.localDescription; + if (answer === null || answer.type !== "answer") { + return yield* new WebRtcPeerError({ + stage: "answer", + cause: new Error("WebRTC peer did not produce a complete answer."), + }); + } + return answer.sdp; + }); + + return { + acceptOffer, + dataChannel: Queue.take(dataChannels).pipe( + Effect.mapError((cause) => new WebRtcPeerError({ stage: "data-channel", cause })), + Effect.raceFirst(Deferred.await(closed)), + ), + closed: Deferred.await(closed), + close: Effect.tryPromise({ + try: () => connection.close(), + catch: (cause) => new WebRtcPeerError({ stage: "connection", cause }), + }).pipe( + Effect.ensuring( + Effect.sync(() => { + stateSubscription.unSubscribe(); + dataChannelSubscription.unSubscribe(); + }), + ), + Effect.ignore, + ), + }; + }); +} + +export const loadWeriftServerPeerFactory: Effect.Effect> = + Effect.tryPromise({ + try: () => import("werift"), + catch: (cause) => new WebRtcPeerError({ stage: "create", cause }), + }).pipe( + Effect.map( + (werift) => + ({ + create: Effect.fn("WeriftServerPeerFactory.create")(function* (iceServers) { + const connection = yield* Effect.try({ + try: () => + new werift.RTCPeerConnection({ + iceServers: iceServers.map((server) => ({ + urls: [...server.urls], + ...(server.username === undefined ? {} : { username: server.username }), + ...(server.credential === undefined ? {} : { credential: server.credential }), + })), + maxMessageSize: MAX_MESSAGE_SIZE, + }), + catch: (cause) => new WebRtcPeerError({ stage: "create", cause }), + }); + const peer = yield* makeWeriftPeer(connection); + yield* Effect.addFinalizer(() => peer.close); + return peer; + }), + randomBytes: (size: number) => + Effect.try({ + try: () => Uint8Array.from(NodeCrypto.randomBytes(size)), + catch: (cause) => new WebRtcPeerError({ stage: "create", cause }), + }), + }) satisfies ServerWebRtcPeerFactory, + ), + Effect.option, + ); diff --git a/packages/websocket-webrtc/src/wire.ts b/packages/websocket-webrtc/src/wire.ts new file mode 100644 index 000000000..9cfe17018 --- /dev/null +++ b/packages/websocket-webrtc/src/wire.ts @@ -0,0 +1,287 @@ +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import type { ClientWebRtcPeerFactory, WebRtcIceServer } from "./peer.ts"; + +export const UPGRADE_QUERY_PARAMETER = "__t3_wsrtc"; +export const PROTOCOL_VERSION = 1; +export const DATA_CHANNEL_LABEL = "t3-websocket-v1"; + +const NONCE_BYTE_LENGTH = 16; +const NONCE_TEXT_LENGTH = 22; +const CONTROL_PREFIX = "\u001et3-wsrtc-v1:"; +const DATA_MAGIC = new TextEncoder().encode("T3WRTC01"); +const DATA_KIND = 1; +const DATA_HEADER_LENGTH = DATA_MAGIC.byteLength + NONCE_TEXT_LENGTH + 1 + 1 + 8 + 2 + 2; +const MAX_DATA_CHANNEL_MESSAGE_BYTES = 16 * 1024; +const MAX_FRAGMENT_PAYLOAD_BYTES = MAX_DATA_CHANNEL_MESSAGE_BYTES - DATA_HEADER_LENGTH; +const MAX_APPLICATION_MESSAGE_BYTES = 16 * 1024 * 1024; +const MAX_FRAGMENT_COUNT = Math.ceil(MAX_APPLICATION_MESSAGE_BYTES / MAX_FRAGMENT_PAYLOAD_BYTES); +const MAX_SEQUENCE = 2n ** 64n - 1n; +const NONCE_PATTERN = /^[A-Za-z0-9_-]{22}$/; + +const AttemptId = Schema.String.check(Schema.isLengthBetween(1, 128)); +const SessionDescription = Schema.String.check(Schema.isLengthBetween(1, 1024 * 1024)); +const BindingToken = Schema.String.check(Schema.isLengthBetween(1, 128)); +const Sequence = Schema.String.check(Schema.isPattern(/^(0|[1-9][0-9]{0,19})$/)); +const IceUrl = Schema.String.check(Schema.isLengthBetween(1, 2048)); +const IceCredential = Schema.String.check(Schema.isMaxLength(512)); + +const WireIceServer = Schema.Struct({ + urls: Schema.Array(IceUrl).check(Schema.isLengthBetween(1, 16)), + username: Schema.optionalKey(IceCredential), + credential: Schema.optionalKey(IceCredential), +}); + +const Hello = Schema.Struct({ + kind: Schema.Literal("hello"), + version: Schema.Literal(PROTOCOL_VERSION), + iceServers: Schema.Array(WireIceServer).check(Schema.isMaxLength(32)), +}); +const HelloAck = Schema.Struct({ + kind: Schema.Literal("hello-ack"), + version: Schema.Literal(PROTOCOL_VERSION), +}); +const FrameStart = Schema.Struct({ + kind: Schema.Literal("frame-start"), + version: Schema.Literal(PROTOCOL_VERSION), +}); +const FrameStartAck = Schema.Struct({ + kind: Schema.Literal("frame-start-ack"), + version: Schema.Literal(PROTOCOL_VERSION), +}); +const Offer = Schema.Struct({ + kind: Schema.Literal("offer"), + attemptId: AttemptId, + sdp: SessionDescription, +}); +const Answer = Schema.Struct({ + kind: Schema.Literal("answer"), + attemptId: AttemptId, + sdp: SessionDescription, + bindingToken: BindingToken, +}); +const Abort = Schema.Struct({ + kind: Schema.Literal("abort"), + attemptId: AttemptId, +}); +const Bind = Schema.Struct({ + kind: Schema.Literal("bind"), + attemptId: AttemptId, + bindingToken: BindingToken, +}); +const BindAck = Schema.Struct({ + kind: Schema.Literal("bind-ack"), + attemptId: AttemptId, +}); +const Cutover = Schema.Struct({ + kind: Schema.Literal("cutover"), + attemptId: AttemptId, +}); +const CutoverAck = Schema.Struct({ + kind: Schema.Literal("cutover-ack"), + attemptId: AttemptId, +}); +const Fallback = Schema.Struct({ + kind: Schema.Literal("fallback"), + attemptId: AttemptId, +}); +const Ack = Schema.Struct({ + kind: Schema.Literal("ack"), + nextSequence: Sequence, +}); + +export const ControlMessage = Schema.Union([ + Hello, + HelloAck, + FrameStart, + FrameStartAck, + Offer, + Answer, + Abort, + Bind, + BindAck, + Cutover, + CutoverAck, + Fallback, + Ack, +]); +export type ControlMessage = typeof ControlMessage.Type; + +const ControlMessageJson = Schema.fromJsonString(ControlMessage); +const encodeControlJson = Schema.encodeSync(ControlMessageJson); +const decodeControlJson = Schema.decodeUnknownEffect(ControlMessageJson); + +export class WebRtcWireError extends Schema.TaggedErrorClass()("WebRtcWireError", { + reason: Schema.Literals([ + "invalid-control", + "invalid-data-frame", + "message-too-large", + "sequence-exhausted", + "invalid-url", + ]), + cause: Schema.optionalKey(Schema.Defect()), +}) { + override get message(): string { + return `WebRTC WebSocket wire protocol failed: ${this.reason}.`; + } +} + +export interface PreparedUpgradeUrl { + readonly url: string; + readonly nonce: string; +} + +export const prepareUpgradeUrl = Effect.fn("WebRtcWebSocket.prepareUpgradeUrl")(function* ( + url: string, + peerFactory: ClientWebRtcPeerFactory, +) { + const nonceBytes = yield* peerFactory.randomBytes(NONCE_BYTE_LENGTH); + const nonce = Encoding.encodeBase64Url(nonceBytes); + const parsed = yield* Effect.try({ + try: () => new URL(url), + catch: (cause) => new WebRtcWireError({ reason: "invalid-url", cause }), + }); + parsed.searchParams.set(UPGRADE_QUERY_PARAMETER, `${PROTOCOL_VERSION}.${nonce}`); + return { url: parsed.toString(), nonce } satisfies PreparedUpgradeUrl; +}); + +export function readUpgradeNonce(url: URL): string | null { + const marker = url.searchParams.get(UPGRADE_QUERY_PARAMETER); + if (marker === null) { + return null; + } + const prefix = `${PROTOCOL_VERSION}.`; + if (!marker.startsWith(prefix)) { + return null; + } + const nonce = marker.slice(prefix.length); + return NONCE_PATTERN.test(nonce) ? nonce : null; +} + +export function encodeControl(nonce: string, message: ControlMessage): string { + return `${CONTROL_PREFIX}${nonce}:${encodeControlJson(message)}`; +} + +export function decodeControl( + nonce: string, + data: string | Uint8Array, +): Effect.Effect, WebRtcWireError> { + const text = typeof data === "string" ? data : new TextDecoder().decode(data); + const prefix = `${CONTROL_PREFIX}${nonce}:`; + if (!text.startsWith(prefix)) { + return Effect.succeed(Option.none()); + } + return decodeControlJson(text.slice(prefix.length)).pipe( + Effect.map(Option.some), + Effect.mapError((cause) => new WebRtcWireError({ reason: "invalid-control", cause })), + ); +} + +export interface ApplicationFragment { + readonly sequence: bigint; + readonly text: boolean; + readonly fragmentIndex: number; + readonly fragmentCount: number; + readonly payload: Uint8Array; +} + +function hasDataPrefix(nonce: string, data: Uint8Array): boolean { + if (data.byteLength < DATA_MAGIC.byteLength + NONCE_TEXT_LENGTH) { + return false; + } + for (let index = 0; index < DATA_MAGIC.byteLength; index += 1) { + if (data[index] !== DATA_MAGIC[index]) { + return false; + } + } + const encodedNonce = new TextDecoder().decode( + data.subarray(DATA_MAGIC.byteLength, DATA_MAGIC.byteLength + NONCE_TEXT_LENGTH), + ); + return encodedNonce === nonce; +} + +export function encodeApplicationFrames( + nonce: string, + sequence: bigint, + chunk: string | Uint8Array, +): Effect.Effect, WebRtcWireError> { + if (sequence < 0n || sequence > MAX_SEQUENCE) { + return Effect.fail(new WebRtcWireError({ reason: "sequence-exhausted" })); + } + const payload = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk; + if (payload.byteLength > MAX_APPLICATION_MESSAGE_BYTES) { + return Effect.fail(new WebRtcWireError({ reason: "message-too-large" })); + } + const fragmentCount = Math.max(1, Math.ceil(payload.byteLength / MAX_FRAGMENT_PAYLOAD_BYTES)); + if (fragmentCount > 65_535) { + return Effect.fail(new WebRtcWireError({ reason: "message-too-large" })); + } + const nonceBytes = new TextEncoder().encode(nonce); + const frames: Array = []; + for (let fragmentIndex = 0; fragmentIndex < fragmentCount; fragmentIndex += 1) { + const start = fragmentIndex * MAX_FRAGMENT_PAYLOAD_BYTES; + const end = Math.min(payload.byteLength, start + MAX_FRAGMENT_PAYLOAD_BYTES); + const frame = new Uint8Array(DATA_HEADER_LENGTH + end - start); + frame.set(DATA_MAGIC, 0); + frame.set(nonceBytes, DATA_MAGIC.byteLength); + const view = new DataView(frame.buffer); + const kindOffset = DATA_MAGIC.byteLength + NONCE_TEXT_LENGTH; + view.setUint8(kindOffset, DATA_KIND); + view.setUint8(kindOffset + 1, typeof chunk === "string" ? 1 : 0); + view.setBigUint64(kindOffset + 2, sequence); + view.setUint16(kindOffset + 10, fragmentIndex); + view.setUint16(kindOffset + 12, fragmentCount); + frame.set(payload.subarray(start, end), DATA_HEADER_LENGTH); + frames.push(frame); + } + return Effect.succeed(frames); +} + +export function decodeApplicationFrame( + nonce: string, + data: Uint8Array, +): Effect.Effect, WebRtcWireError> { + if (!hasDataPrefix(nonce, data)) { + return Effect.succeed(Option.none()); + } + if (data.byteLength < DATA_HEADER_LENGTH || data.byteLength > MAX_DATA_CHANNEL_MESSAGE_BYTES) { + return Effect.fail(new WebRtcWireError({ reason: "invalid-data-frame" })); + } + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const kindOffset = DATA_MAGIC.byteLength + NONCE_TEXT_LENGTH; + const kind = view.getUint8(kindOffset); + const textFlag = view.getUint8(kindOffset + 1); + const fragmentIndex = view.getUint16(kindOffset + 10); + const fragmentCount = view.getUint16(kindOffset + 12); + if ( + kind !== DATA_KIND || + (textFlag !== 0 && textFlag !== 1) || + fragmentCount === 0 || + fragmentCount > MAX_FRAGMENT_COUNT || + fragmentIndex >= fragmentCount + ) { + return Effect.fail(new WebRtcWireError({ reason: "invalid-data-frame" })); + } + return Effect.succeed( + Option.some({ + sequence: view.getBigUint64(kindOffset + 2), + text: textFlag === 1, + fragmentIndex, + fragmentCount, + payload: data.slice(DATA_HEADER_LENGTH), + }), + ); +} + +export function wireIceServers( + iceServers: ReadonlyArray, +): ReadonlyArray { + return iceServers.map((server) => ({ + urls: [...server.urls], + ...(server.username === undefined ? {} : { username: server.username }), + ...(server.credential === undefined ? {} : { credential: server.credential }), + })); +} diff --git a/packages/websocket-webrtc/tsconfig.json b/packages/websocket-webrtc/tsconfig.json new file mode 100644 index 000000000..564a59900 --- /dev/null +++ b/packages/websocket-webrtc/tsconfig.json @@ -0,0 +1,4 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index be14c92e1..c2db11dd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,9 @@ importers: '@clerk/expo': specifier: 4.2.0 version: 4.2.0(patch_hash=72e426f44fc1cde16fc2cbba3d1e96cdca7c6d957faa73d0fe6b43948608a6c1)(expo-auth-session@56.0.14(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(expo-constants@56.0.18)(expo-crypto@56.0.4(expo@56.0.12))(expo-secure-store@56.0.4(expo@56.0.12))(expo-web-browser@56.0.5(expo@56.0.12)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)))(expo@56.0.12)(react-dom@19.2.3(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3)(typescript@6.0.3) + '@config-plugins/react-native-webrtc': + specifier: ^15.0.1 + version: 15.0.2(expo@56.0.12) '@effect/atom-react': specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))(react@19.2.3)(scheduler@0.27.0) @@ -268,6 +271,9 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared + '@t3tools/websocket-webrtc': + specifier: workspace:* + version: link:../../packages/websocket-webrtc '@tabler/icons-react-native': specifier: ^3.44.0 version: 3.44.0(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react@19.2.3) @@ -409,6 +415,9 @@ importers: react-native-svg: specifier: 15.15.4 version: 15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) + react-native-webrtc: + specifier: ^124.0.8 + version: 124.0.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) react-native-webview: specifier: ^13.16.1 version: 13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) @@ -498,6 +507,9 @@ importers: '@t3tools/web': specifier: workspace:* version: link:../web + '@t3tools/websocket-webrtc': + specifier: workspace:* + version: link:../../packages/websocket-webrtc '@types/bun': specifier: 1.3.14 version: 1.3.14 @@ -567,6 +579,9 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../../packages/shared + '@t3tools/websocket-webrtc': + specifier: workspace:* + version: link:../../packages/websocket-webrtc '@tanstack/react-pacer': specifier: ^0.19.4 version: 0.19.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -762,6 +777,9 @@ importers: '@t3tools/shared': specifier: workspace:* version: link:../shared + '@t3tools/websocket-webrtc': + specifier: workspace:* + version: link:../websocket-webrtc effect: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) @@ -911,6 +929,23 @@ importers: specifier: 'catalog:' version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + packages/websocket-webrtc: + dependencies: + effect: + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) + devDependencies: + '@types/node': + specifier: 24.12.4 + version: 24.12.4 + vite-plus: + specifier: 'catalog:' + version: 0.2.2(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + optionalDependencies: + werift: + specifier: ^0.24.4 + version: 0.24.4 + scripts: dependencies: '@effect/platform-node': @@ -1865,6 +1900,11 @@ packages: '@cloudflare/workers-types@5.20260726.1': resolution: {integrity: sha512-fKgRSm3sDmOdak1LGWehS4vSPSj7/zeu0NfmE62VPjBMqWgODcOGljYvq6A75sL+7YfY3iGFGb0jVEDYq+hlmw==} + '@config-plugins/react-native-webrtc@15.0.2': + resolution: {integrity: sha512-sH4T7Z4P2RowV91k9CEwEr0unn+396NBexxCNZwRXuJXwguDU1qZWpE6fcwN0730B8uiS83F7+CLTyhpC7qRHQ==} + peerDependencies: + expo: '>=56' + '@distilled.cloud/aws@0.30.2': resolution: {integrity: sha512-Uw2yZf7PJ2ienrKG49HN1ajnke65mTtHBUrSb/3ykeZ/8cLaSgwVhPscHxSzGByY0TEtYcYKNNmfUVISgGQl9g==} peerDependencies: @@ -2712,6 +2752,14 @@ packages: cpu: [x64, arm64] os: [darwin, linux, win32] + '@fidm/asn1@1.0.4': + resolution: {integrity: sha512-esd1jyNvRb2HVaQGq2Gg8Z0kbQPXzV9Tq5Z14KNIov6KfFD6PTaRIO8UpcsYiTNzOqJpmyzWgVTrUwFV3UF4TQ==} + engines: {node: '>= 8'} + + '@fidm/x509@1.2.1': + resolution: {integrity: sha512-nwc2iesjyc9hkuzcrMCBXQRn653XuAUKorfWM8PZyJawiy1QzLj4vahwzaI25+pfpwOLvMzbJ0uKpWLDNmo16w==} + engines: {node: '>= 8'} + '@floating-ui/core@1.7.5': resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} @@ -2987,6 +3035,9 @@ packages: react-native: optional: true + '@leichtgewicht/ip-codec@2.0.5': + resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} + '@lexical/clipboard@0.41.0': resolution: {integrity: sha512-Ex5lPkb4NBBX1DCPzOAIeHBJFH1bJcmATjREaqpnTfxCbuOeQkt44wchezUA0oDl+iAxNZ3+pLLWiUju9icoSA==} @@ -3582,9 +3633,39 @@ packages: resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@peculiar/asn1-cms@2.9.0': + resolution: {integrity: sha512-VKQz6sJYgSxtGaK6UdnNBUx7hmSdg0K331qrEWh5qxpQsyZGWBjxbq05AJ2bTWWjd7d+nJIxFzwvsR5T7DWC/A==} + + '@peculiar/asn1-csr@2.9.0': + resolution: {integrity: sha512-SbxRzHiWnRdiDuiiji/RLsJxu2au4hmSZSKK7GQOgNr2BVvheAlFQST9qqzRchUcZ6wvcyRuPXIfYXVzLoZ/5g==} + + '@peculiar/asn1-ecc@2.9.0': + resolution: {integrity: sha512-vNspHtTd9h6e8c2lMW+B/VHEUD+HRFV0fj/Gvz7SaJbwiecA8dxd96UTFPYI1PR8k7qwjjW9AFEyI+LuyVi4Kw==} + + '@peculiar/asn1-pfx@2.9.0': + resolution: {integrity: sha512-A6bX+gZr69U38Pg1mWvPrM1eRba6L6kLR8iVG+bJtKj3qSv2rSNmlXLtej7ZOkEWt6xL6PiJD73uFEdQI3BXCg==} + + '@peculiar/asn1-pkcs8@2.9.0': + resolution: {integrity: sha512-1JH4FliKQ3trkMD17X+bKGsph5TsiM+AiiqnVKr1wxA/GoUSDHiWG/zROzLAbDIA7eclBCjQsp8hrBEJk7LRUQ==} + + '@peculiar/asn1-pkcs9@2.9.0': + resolution: {integrity: sha512-igArY6bpCI6tOPm2EU9QPrXuKq2s1iraLCTym4UlooYdzcRDIPCRUN9TAEcqZ5rZhA+HiPdFKTbDP9vneN/tsg==} + + '@peculiar/asn1-rsa@2.9.0': + resolution: {integrity: sha512-vOD7Q4UmQWhlMYuWJawS2sD+/JcPKJNtyQuFZx09r+KFhm5/HxfGak63y/m56cpBjmb5k6PeRlQf1fvLQAieUA==} + '@peculiar/asn1-schema@2.8.0': resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + '@peculiar/asn1-schema@2.9.0': + resolution: {integrity: sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q==} + + '@peculiar/asn1-x509-attr@2.9.0': + resolution: {integrity: sha512-f+u+EyGjfPvPzvr0+rlh/TFEO7LWt84mEwQynCUYr4F6urf/8s6+ESJ23qfSn1aamkZR6AuBNaC62iEsGvp0+w==} + + '@peculiar/asn1-x509@2.9.0': + resolution: {integrity: sha512-b9Na83rhRFQBd5CuMmuEqokYhmyWUbG7mNZl/thGhBwLpCdiZ85TZ3WFhF7OVgOekC5uKhLk4C3HKtdiScCMdQ==} + '@peculiar/json-schema@1.1.12': resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} engines: {node: '>=8.0.0'} @@ -3596,6 +3677,10 @@ packages: resolution: {integrity: sha512-ODOov0sGMJMf3jPonOkgGqPknTsu+DdQ7kD++gz8aI+aFMOMHFbWAA2taqXXVTdP+OTOQR/znGvSpmkeI0WTYQ==} engines: {node: '>=14.18.0'} + '@peculiar/x509@1.14.3': + resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} + engines: {node: '>=20.0.0'} + '@pierre/diffs@1.3.0-beta.10': resolution: {integrity: sha512-efyFM9GRfI6WkmHJP0CnZBopuM8yCwGqIKbZHoe1D5PV15VDkr7Vpi8EZt40AYrN1km//utQtYhHDSwt2KwjSg==} peerDependencies: @@ -4396,6 +4481,10 @@ packages: '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + '@shinyoshiaki/binary-data@0.6.1': + resolution: {integrity: sha512-7HDb/fQAop2bCmvDIzU5+69i+UJaFgIVp99h1VzK1mpg1JwSODOkjbqD7ilTYnqlnadF8C4XjpwpepxDsGY6+w==} + engines: {node: '>=6'} + '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -4824,6 +4913,12 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/dom-mediacapture-transform@0.1.12': + resolution: {integrity: sha512-d7/QsLRwF864A5mgIM/YrfiglHoYn7zgCcAoJgW404r+2DwnNr7EBbLnCWpmOMgH8y0te73L1AV6H1bmauaWFw==} + + '@types/dom-webcodecs@0.1.13': + resolution: {integrity: sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==} + '@types/emscripten@1.41.5': resolution: {integrity: sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==} @@ -5184,10 +5279,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@yuuang/ffi-rs-android-arm64@1.3.2': resolution: {integrity: sha512-eDYLT0kVBkp7e2BwdRDmt6N1rkeDPUHDefk3ZX0/nok+GLsqfy1WBoSL3Yg7HVXN1EyW8OBVc2uK8Zq8HbmaSA==} @@ -5666,6 +5763,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bufferutil@4.1.0: resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} engines: {node: '>=6.14.2'} @@ -6055,6 +6155,24 @@ packages: supports-color: optional: true + debug@4.3.4: + resolution: {integrity: sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -6154,6 +6272,10 @@ packages: dmg-builder@26.15.6: resolution: {integrity: sha512-nr5vQxEhM0REomp1qiHbc6V99yrfBZy+wUU56VXADfSOlLj8PdLqsHiRe7b+FbqKesiyv4ax+k1GVwGonYKuCg==} + dns-packet@5.6.1: + resolution: {integrity: sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==} + engines: {node: '>=6'} + dnssd-advertise@1.1.4: resolution: {integrity: sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==} @@ -7253,6 +7375,9 @@ packages: idb-keyval@6.2.1: resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -7401,6 +7526,10 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@2.0.4: + resolution: {integrity: sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==} + engines: {node: '>=0.10.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -7445,6 +7574,10 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} + isobject@3.0.1: + resolution: {integrity: sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==} + engines: {node: '>=0.10.0'} + isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} @@ -7977,6 +8110,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + mediabunny@1.55.1: + resolution: {integrity: sha512-JDdTEUOw9g6Ey8eWc2Ei63GmRGtGTJoE56SE7CHikwzS8i9xpsWcve4bEiUw47d4j49L3r7MXDFsH/5oZIhjBw==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -8271,6 +8407,9 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.2: + resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -8294,6 +8433,10 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + multicast-dns@7.2.5: + resolution: {integrity: sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==} + hasBin: true + multipasta@0.2.8: resolution: {integrity: sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q==} @@ -9058,6 +9201,11 @@ packages: peerDependencies: react-native: '*' + react-native-webrtc@124.0.8: + resolution: {integrity: sha512-uuQxvmk+mvnk5U0tr+1N42sKZqgm41fJrBA+fmCvML9J9P4roSh2So82t5RHAlu/vE9vxu5AKgivAiH61clCBg==} + peerDependencies: + react-native: '>=0.60.0' + react-native-webview@13.16.1: resolution: {integrity: sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==} peerDependencies: @@ -9161,6 +9309,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + reflect-metadata@0.2.2: + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} + reftools@1.1.9: resolution: {integrity: sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==} @@ -9744,6 +9895,9 @@ packages: throat@5.0.0: resolution: {integrity: sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==} + thunky@1.1.0: + resolution: {integrity: sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==} + timestring@6.0.0: resolution: {integrity: sha512-wMctrWD2HZZLuIlchlkE2dfXJh7J2KDI9Dwl+2abPYg0mswQHfOAyQW3jJg1pY5VfttSINZuKcXoB3FGypVklA==} engines: {node: '>=8'} @@ -9839,9 +9993,19 @@ packages: ts-algebra@2.0.0: resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsyringe@4.10.0: + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} + engines: {node: '>= 6.0.0'} + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + type-fest@0.13.1: resolution: {integrity: sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg==} engines: {node: '>=10'} @@ -10324,6 +10488,10 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + werift@0.24.4: + resolution: {integrity: sha512-NoROZ11L/ZqAB5ombCB4VBuVNdIZfwI493zlxlIX7BlwfWRy55eDJdtWMC2VX35yBXFaWwA8NtYbmJ8qWtVk9Q==} + engines: {node: '>=16'} + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -11694,6 +11862,10 @@ snapshots: '@cloudflare/workers-types@5.20260726.1': {} + '@config-plugins/react-native-webrtc@15.0.2(expo@56.0.12)': + dependencies: + expo: 56.0.12(8895228379997a2a064f9644cda56ed0) + '@distilled.cloud/aws@0.30.2(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6))': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -12808,6 +12980,15 @@ snapshots: '@ff-labs/fff-bin-win32-arm64': 0.9.4 '@ff-labs/fff-bin-win32-x64': 0.9.4 + '@fidm/asn1@1.0.4': + optional: true + + '@fidm/x509@1.2.1': + dependencies: + '@fidm/asn1': 1.0.4 + tweetnacl: 1.0.3 + optional: true + '@floating-ui/core@1.7.5': dependencies: '@floating-ui/utils': 0.2.11 @@ -13030,6 +13211,9 @@ snapshots: optionalDependencies: react-dom: 19.2.6(react@19.2.6) + '@leichtgewicht/ip-codec@2.0.5': + optional: true + '@lexical/clipboard@0.41.0': dependencies: '@lexical/html': 0.41.0 @@ -13584,11 +13768,97 @@ snapshots: '@oxlint/plugins@1.68.0': {} + '@peculiar/asn1-cms@2.9.0': + dependencies: + '@peculiar/asn1-schema': 2.9.0 + '@peculiar/asn1-x509': 2.9.0 + '@peculiar/asn1-x509-attr': 2.9.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-csr@2.9.0': + dependencies: + '@peculiar/asn1-schema': 2.9.0 + '@peculiar/asn1-x509': 2.9.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-ecc@2.9.0': + dependencies: + '@peculiar/asn1-schema': 2.9.0 + '@peculiar/asn1-x509': 2.9.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-pfx@2.9.0': + dependencies: + '@peculiar/asn1-cms': 2.9.0 + '@peculiar/asn1-pkcs8': 2.9.0 + '@peculiar/asn1-rsa': 2.9.0 + '@peculiar/asn1-schema': 2.9.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-pkcs8@2.9.0': + dependencies: + '@peculiar/asn1-schema': 2.9.0 + '@peculiar/asn1-x509': 2.9.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-pkcs9@2.9.0': + dependencies: + '@peculiar/asn1-cms': 2.9.0 + '@peculiar/asn1-pfx': 2.9.0 + '@peculiar/asn1-pkcs8': 2.9.0 + '@peculiar/asn1-schema': 2.9.0 + '@peculiar/asn1-x509': 2.9.0 + '@peculiar/asn1-x509-attr': 2.9.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-rsa@2.9.0': + dependencies: + '@peculiar/asn1-schema': 2.9.0 + '@peculiar/asn1-x509': 2.9.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + '@peculiar/asn1-schema@2.8.0': dependencies: '@peculiar/utils': 2.0.3 asn1js: 3.0.10 tslib: 2.8.1 + optional: true + + '@peculiar/asn1-schema@2.9.0': + dependencies: + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + + '@peculiar/asn1-x509-attr@2.9.0': + dependencies: + '@peculiar/asn1-schema': 2.9.0 + '@peculiar/asn1-x509': 2.9.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-x509@2.9.0': + dependencies: + '@peculiar/asn1-schema': 2.9.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true '@peculiar/json-schema@1.1.12': dependencies: @@ -13600,12 +13870,27 @@ snapshots: '@peculiar/webcrypto@1.7.1': dependencies: - '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-schema': 2.9.0 '@peculiar/json-schema': 1.1.12 '@peculiar/utils': 2.0.3 tslib: 2.8.1 webcrypto-core: 1.9.2 + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.9.0 + '@peculiar/asn1-csr': 2.9.0 + '@peculiar/asn1-ecc': 2.9.0 + '@peculiar/asn1-pkcs9': 2.9.0 + '@peculiar/asn1-rsa': 2.9.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.9.0 + pvtsutils: 1.3.6 + reflect-metadata: 0.2.2 + tslib: 2.8.1 + tsyringe: 4.10.0 + optional: true + '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.1.0 @@ -14577,6 +14862,12 @@ snapshots: '@shikijs/vscode-textmate@10.0.2': {} + '@shinyoshiaki/binary-data@0.6.1': + dependencies: + generate-function: 2.3.1 + is-plain-object: 2.0.4 + optional: true + '@sinclair/typebox@0.27.10': {} '@sindresorhus/is@4.6.0': {} @@ -15004,6 +15295,14 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/dom-mediacapture-transform@0.1.12': + dependencies: + '@types/dom-webcodecs': 0.1.13 + optional: true + + '@types/dom-webcodecs@0.1.13': + optional: true + '@types/emscripten@1.41.5': {} '@types/estree-jsx@1.0.5': @@ -16009,6 +16308,12 @@ snapshots: buffer-from@1.1.2: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + optional: true + bufferutil@4.1.0: dependencies: node-gyp-build: 4.8.4 @@ -16389,6 +16694,15 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.3.4: + dependencies: + ms: 2.1.2 + + debug@4.4.0: + dependencies: + ms: 2.1.3 + optional: true + debug@4.4.3: dependencies: ms: 2.1.3 @@ -16473,6 +16787,11 @@ snapshots: - electron-builder-squirrel-windows - supports-color + dns-packet@5.6.1: + dependencies: + '@leichtgewicht/ip-codec': 2.0.5 + optional: true + dnssd-advertise@1.1.4: {} dom-accessibility-api@0.5.16: {} @@ -17961,6 +18280,9 @@ snapshots: idb-keyval@6.2.1: optional: true + ieee754@1.2.1: + optional: true + ignore@5.3.2: {} ignore@7.0.5: {} @@ -18095,6 +18417,11 @@ snapshots: is-plain-obj@4.1.0: {} + is-plain-object@2.0.4: + dependencies: + isobject: 3.0.1 + optional: true + is-promise@4.0.0: {} is-property@1.0.2: {} @@ -18123,6 +18450,9 @@ snapshots: isexe@4.0.0: {} + isobject@3.0.1: + optional: true + isomorphic.js@0.2.5: {} jake@10.9.4: @@ -18677,6 +19007,12 @@ snapshots: media-typer@1.1.0: {} + mediabunny@1.55.1: + dependencies: + '@types/dom-mediacapture-transform': 0.1.12 + '@types/dom-webcodecs': 0.1.13 + optional: true + memoize-one@5.2.1: {} memory-pager@1.5.0: {} @@ -19137,6 +19473,8 @@ snapshots: ms@2.0.0: {} + ms@2.1.2: {} + ms@2.1.3: {} msgpackr-extract@3.0.4: @@ -19181,6 +19519,12 @@ snapshots: muggle-string@0.4.1: {} + multicast-dns@7.2.5: + dependencies: + dns-packet: 5.6.1 + thunky: 1.1.0 + optional: true + multipasta@0.2.8: {} multitars@1.0.0: {} @@ -20015,6 +20359,14 @@ snapshots: dependencies: react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + react-native-webrtc@124.0.8(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)): + dependencies: + base64-js: 1.5.1 + debug: 4.3.4 + react-native: 0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - supports-color + react-native-webview@13.16.1(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3): dependencies: escape-string-regexp: 4.0.0 @@ -20262,6 +20614,9 @@ snapshots: dependencies: redis-errors: 1.2.0 + reflect-metadata@0.2.2: + optional: true + reftools@1.1.9: {} regenerate-unicode-properties@10.2.2: @@ -21046,6 +21401,9 @@ snapshots: throat@5.0.0: {} + thunky@1.1.0: + optional: true + timestring@6.0.0: {} tiny-async-pool@1.3.0: @@ -21117,8 +21475,19 @@ snapshots: ts-algebra@2.0.0: {} + tslib@1.14.1: + optional: true + tslib@2.8.1: {} + tsyringe@4.10.0: + dependencies: + tslib: 1.14.1 + optional: true + + tweetnacl@1.0.3: + optional: true + type-fest@0.13.1: optional: true @@ -21607,7 +21976,7 @@ snapshots: webcrypto-core@1.9.2: dependencies: - '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-schema': 2.9.0 '@peculiar/json-schema': 1.1.12 '@peculiar/utils': 2.0.3 asn1js: 3.0.10 @@ -21619,6 +21988,21 @@ snapshots: webpack-virtual-modules@0.6.2: {} + werift@0.24.4: + dependencies: + '@fidm/x509': 1.2.1 + '@noble/curves': 1.9.1 + '@peculiar/x509': 1.14.3 + '@shinyoshiaki/binary-data': 0.6.1 + buffer: 6.0.3 + debug: 4.4.0 + mediabunny: 1.55.1 + multicast-dns: 7.2.5 + tweetnacl: 1.0.3 + transitivePeerDependencies: + - supports-color + optional: true + whatwg-fetch@3.6.20: {} whatwg-url-minimum@0.1.2: {} From 6a75734ad6613d97a2023355ad4227255f0540be Mon Sep 17 00:00:00 2001 From: Taras Date: Wed, 19 Aug 2026 19:11:52 +0300 Subject: [PATCH 2/5] fix(rpc): preserve webrtc fallback delivery --- nix/package.nix | 2 +- packages/websocket-webrtc/package.json | 2 + packages/websocket-webrtc/src/server.test.ts | 293 +++++++++++++++++++ packages/websocket-webrtc/src/server.ts | 4 +- pnpm-lock.yaml | 3 + scripts/release-smoke.ts | 1 + 6 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 packages/websocket-webrtc/src/server.test.ts diff --git a/nix/package.nix b/nix/package.nix index 70b8c2154..c7d2da287 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -48,7 +48,7 @@ stdenv.mkDerivation (finalAttrs: { version = sourceVersion; inherit pnpm; fetcherVersion = 4; - hash = "sha256-t9T9CjSgPSttkfKNTLaC5DHVb9df5JN7CtMxZSqDx28="; + hash = "sha256-bFcxoeS/rYm/9BoQ0bj7khoOChOOiMqWj/AKOiOoGKA="; }; nativeBuildInputs = [ diff --git a/packages/websocket-webrtc/package.json b/packages/websocket-webrtc/package.json index 1fb470869..83a89964b 100644 --- a/packages/websocket-webrtc/package.json +++ b/packages/websocket-webrtc/package.json @@ -29,12 +29,14 @@ } }, "scripts": { + "test": "vp test run", "typecheck": "tsgo --noEmit" }, "dependencies": { "effect": "catalog:" }, "devDependencies": { + "@effect/vitest": "catalog:", "@types/node": "catalog:", "vite-plus": "catalog:" }, diff --git a/packages/websocket-webrtc/src/server.test.ts b/packages/websocket-webrtc/src/server.test.ts new file mode 100644 index 000000000..afacd0627 --- /dev/null +++ b/packages/websocket-webrtc/src/server.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Socket from "effect/unstable/socket/Socket"; + +import type { ServerWebRtcPeerFactory, WebRtcDataChannelPort } from "./peer.ts"; +import { makeServerLogicalSocket } from "./server.ts"; +import { + decodeApplicationFrame, + decodeControl, + encodeApplicationFrames, + encodeControl, +} from "./wire.ts"; + +const NONCE = "abcdefghijklmnopqrstuv"; +const ATTEMPT_ID = "attempt-1"; + +type ApplicationChunk = string | Uint8Array; + +const makeRawSocket = Effect.fn("WebRtcServerTest.makeRawSocket")(function* () { + const inbound = yield* Queue.unbounded(); + const outbound = yield* Queue.unbounded(); + + const runRaw = ( + handler: (data: ApplicationChunk) => Effect.Effect | void, + options?: { readonly onOpen?: Effect.Effect | undefined }, + ): Effect.Effect => + Effect.gen(function* () { + yield* options?.onOpen ?? Effect.void; + return yield* Queue.take(inbound).pipe( + Effect.flatMap((data) => { + const result = handler(data); + return Effect.isEffect(result) ? result : Effect.void; + }), + Effect.forever, + ); + }); + + const writer = Effect.succeed((chunk: ApplicationChunk | Socket.CloseEvent) => { + if (Socket.isCloseEvent(chunk)) { + return Effect.void; + } + return Queue.offer(outbound, chunk).pipe(Effect.asVoid); + }); + + return { + socket: Socket.make({ runRaw, writer }), + receive: (chunk: ApplicationChunk) => Queue.offer(inbound, chunk).pipe(Effect.asVoid), + takeSent: Queue.take(outbound), + }; +}); + +const makeDataChannel = Effect.fn("WebRtcServerTest.makeDataChannel")(function* () { + const sent = yield* Queue.unbounded(); + const messageListenerAttached = yield* Deferred.make(); + const openListeners = new Set<() => void>(); + const messageListeners = new Set<(data: Uint8Array) => void>(); + const closeListeners = new Set<() => void>(); + const errorListeners = new Set<(cause: unknown) => void>(); + let open = true; + + const port: WebRtcDataChannelPort = { + label: "t3-websocket-v1", + ordered: true, + isOpen: () => open, + bufferedAmount: () => 0, + send: (data) => { + Queue.offerUnsafe(sent, Uint8Array.from(data)); + }, + close: () => { + open = false; + for (const listener of closeListeners) { + listener(); + } + }, + onOpen: (listener) => { + openListeners.add(listener); + return () => openListeners.delete(listener); + }, + onMessage: (listener) => { + messageListeners.add(listener); + Deferred.doneUnsafe(messageListenerAttached, Effect.void); + return () => messageListeners.delete(listener); + }, + onClose: (listener) => { + closeListeners.add(listener); + return () => closeListeners.delete(listener); + }, + onError: (listener) => { + errorListeners.add(listener); + return () => errorListeners.delete(listener); + }, + }; + + return { + port, + awaitMessageListener: Deferred.await(messageListenerAttached), + emitMessage: (data: Uint8Array) => + Effect.sync(() => { + for (const listener of messageListeners) { + listener(data); + } + }), + closeRemote: Effect.sync(() => { + open = false; + for (const listener of closeListeners) { + listener(); + } + }), + takeSent: Queue.take(sent), + }; +}); + +function makePeerFactory(port: WebRtcDataChannelPort): ServerWebRtcPeerFactory { + return { + create: () => + Effect.succeed({ + acceptOffer: () => Effect.succeed("answer-sdp"), + dataChannel: Effect.succeed(port), + closed: Effect.never, + close: Effect.void, + }), + randomBytes: (size) => Effect.succeed(new Uint8Array(size).fill(1)), + }; +} + +const decodeControlChunk = Effect.fn("WebRtcServerTest.decodeControlChunk")(function* ( + chunk: ApplicationChunk, +) { + const message = yield* decodeControl(NONCE, chunk); + return Option.getOrThrow(message); +}); + +const makeConnectedServer = Effect.fn("WebRtcServerTest.makeConnectedServer")(function* () { + const raw = yield* makeRawSocket(); + const dataChannel = yield* makeDataChannel(); + const received = yield* Queue.unbounded(); + const socket = makeServerLogicalSocket({ + socket: raw.socket, + nonce: NONCE, + peerFactory: makePeerFactory(dataChannel.port), + }); + + yield* socket + .runRaw((chunk) => Queue.offer(received, chunk).pipe(Effect.asVoid)) + .pipe(Effect.forkScoped); + + expect((yield* decodeControlChunk(yield* raw.takeSent)).kind).toBe("hello"); + yield* raw.receive( + encodeControl(NONCE, { + kind: "hello-ack", + version: 1, + }), + ); + yield* raw.receive( + encodeControl(NONCE, { + kind: "frame-start", + version: 1, + }), + ); + expect((yield* decodeControlChunk(yield* raw.takeSent)).kind).toBe("frame-start-ack"); + + yield* raw.receive( + encodeControl(NONCE, { + kind: "offer", + attemptId: ATTEMPT_ID, + sdp: "offer-sdp", + }), + ); + const answer = yield* decodeControlChunk(yield* raw.takeSent); + if (answer.kind !== "answer") { + return yield* Effect.die(new Error(`Expected answer, received ${answer.kind}.`)); + } + + yield* dataChannel.awaitMessageListener; + yield* dataChannel.emitMessage( + new TextEncoder().encode( + encodeControl(NONCE, { + kind: "bind", + attemptId: ATTEMPT_ID, + bindingToken: answer.bindingToken, + }), + ), + ); + expect((yield* decodeControlChunk(yield* dataChannel.takeSent)).kind).toBe("bind-ack"); + + yield* raw.receive( + encodeControl(NONCE, { + kind: "cutover", + attemptId: ATTEMPT_ID, + }), + ); + expect((yield* decodeControlChunk(yield* raw.takeSent)).kind).toBe("cutover-ack"); + + return { + dataChannel, + raw, + takeReceived: Queue.take(received), + writer: yield* socket.writer, + }; +}); + +function expectApplicationFrame(chunk: ApplicationChunk, sequence: bigint) { + expect(chunk).toBeInstanceOf(Uint8Array); + if (typeof chunk === "string") { + throw new Error("Expected a binary application frame."); + } + return decodeApplicationFrame(NONCE, chunk).pipe( + Effect.map(Option.getOrThrow), + Effect.tap((fragment) => + Effect.sync(() => { + expect(fragment.sequence).toBe(sequence); + }), + ), + ); +} + +describe("WebRTC server logical socket", () => { + it.effect("replays unacknowledged frames when abort arrives before DataChannel close", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* makeConnectedServer(); + + yield* server.writer("before-abort"); + const rtcFrame = yield* server.dataChannel.takeSent; + yield* expectApplicationFrame(rtcFrame, 0n); + + yield* server.raw.receive( + encodeControl(NONCE, { + kind: "abort", + attemptId: ATTEMPT_ID, + }), + ); + const replayedFrame = yield* server.raw.takeSent; + expect(replayedFrame).toEqual(rtcFrame); + + yield* server.writer("after-abort"); + yield* expectApplicationFrame(yield* server.raw.takeSent, 1n); + }), + ), + ); + + it.effect("replays unacknowledged frames after DataChannel failure", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* makeConnectedServer(); + + yield* server.writer("before-close"); + const rtcFrame = yield* server.dataChannel.takeSent; + yield* server.dataChannel.closeRemote; + + expect(yield* server.raw.takeSent).toEqual(rtcFrame); + const fallback = yield* decodeControlChunk(yield* server.raw.takeSent); + expect(fallback).toEqual({ kind: "fallback", attemptId: ATTEMPT_ID }); + + yield* server.writer("after-close"); + yield* expectApplicationFrame(yield* server.raw.takeSent, 1n); + }), + ), + ); + + it.effect("reassembles fragmented messages once when fragments are duplicated", () => + Effect.scoped( + Effect.gen(function* () { + const server = yield* makeConnectedServer(); + const firstMessage = "x".repeat(20 * 1024); + const firstFrames = yield* encodeApplicationFrames(NONCE, 0n, firstMessage); + const firstFrame = firstFrames[0]; + if (firstFrame === undefined) { + return yield* Effect.die(new Error("Expected the message to produce a frame.")); + } + + yield* server.dataChannel.emitMessage(firstFrame); + yield* server.dataChannel.emitMessage(firstFrame); + for (const frame of firstFrames.slice(1)) { + yield* server.dataChannel.emitMessage(frame); + } + expect(yield* server.takeReceived).toBe(firstMessage); + + for (const frame of firstFrames) { + yield* server.dataChannel.emitMessage(frame); + } + const secondFrames = yield* encodeApplicationFrames(NONCE, 1n, "second"); + for (const frame of secondFrames) { + yield* server.dataChannel.emitMessage(frame); + } + expect(yield* server.takeReceived).toBe("second"); + }), + ), + ); +}); diff --git a/packages/websocket-webrtc/src/server.ts b/packages/websocket-webrtc/src/server.ts index b9b3c0061..a39fcdfa8 100644 --- a/packages/websocket-webrtc/src/server.ts +++ b/packages/websocket-webrtc/src/server.ts @@ -222,7 +222,9 @@ function makeServerDriver(options: { case "abort": { const attempt = currentAttempt; return source === "websocket" && attempt?.attemptId === message.attemptId - ? disposeAttempt(attempt) + ? options.session + .fallbackToWebSocket(attempt.attemptId) + .pipe(Effect.andThen(disposeAttempt(attempt))) : Effect.void; } case "ack": diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2db11dd3..45b75a45c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -935,6 +935,9 @@ importers: specifier: 4.0.0-beta.103 version: 4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6) devDependencies: + '@effect/vitest': + specifier: 4.0.0-beta.103 + version: 4.0.0-beta.103(patch_hash=a16b1e870d8c29e4a98b17cc4c638a4ff471753d8ca3487f78a22b759caf951b)(effect@4.0.0-beta.103(patch_hash=af36b7948b6f9c56623074662b51dade5699880c1a7c71245de73e13c3185fb6)) '@types/node': specifier: 24.12.4 version: 24.12.4 diff --git a/scripts/release-smoke.ts b/scripts/release-smoke.ts index 45d2dd436..186855a69 100644 --- a/scripts/release-smoke.ts +++ b/scripts/release-smoke.ts @@ -29,6 +29,7 @@ const workspaceFiles = [ "packages/shared/package.json", "packages/ssh/package.json", "packages/tailscale/package.json", + "packages/websocket-webrtc/package.json", "packages/effect-acp/package.json", "packages/effect-codex-app-server/package.json", "scripts/package.json", From f5ccca6c84c780ef2c32b699fd44f4a111b7ec6c Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 22:31:36 +0300 Subject: [PATCH 3/5] fix(rpc): make webrtc reliable and observable --- .env.example | 13 + apps/mobile/src/connection/platform.ts | 26 +- .../features/settings/SettingsRouteScreen.tsx | 24 ++ .../src/persistence/mobile-preferences.ts | 5 + apps/server/src/server.ts | 3 +- .../src/webrtc/WebRtcIceServerProvider.ts | 280 ++++++++++++++++++ apps/server/src/ws.ts | 4 + .../components/BranchToolbar.logic.test.ts | 39 --- .../web/src/components/BranchToolbar.logic.ts | 11 - apps/web/src/components/BranchToolbar.tsx | 72 +++-- .../BranchToolbarEnvironmentSelector.tsx | 87 +++--- apps/web/src/components/ChatView.tsx | 6 +- .../EnvironmentConnectionStatus.tsx | 88 ++++++ .../components/settings/SettingsPanels.tsx | 25 ++ .../src/components/settings/settingsSearch.ts | 5 + apps/web/src/connection/platform.ts | 17 +- apps/web/src/hooks/useSettings.ts | 8 +- apps/web/src/state/clientSettings.ts | 5 + apps/web/src/state/session.ts | 26 ++ docs/internals/websocket-webrtc-upgrade.md | 34 +++ .../src/connection/supervisor.ts | 18 +- .../client-runtime/src/connection/wakeups.ts | 11 +- packages/client-runtime/src/rpc/index.ts | 2 +- packages/client-runtime/src/rpc/session.ts | 24 +- packages/client-runtime/src/state/session.ts | 76 +++++ packages/contracts/src/settings.ts | 2 + packages/websocket-webrtc/src/client.ts | 5 + packages/websocket-webrtc/src/peer.ts | 8 +- packages/websocket-webrtc/src/socket.ts | 8 +- packages/websocket-webrtc/src/werift.ts | 2 + pnpm-lock.yaml | 13 +- pnpm-workspace.yaml | 1 + 32 files changed, 787 insertions(+), 161 deletions(-) create mode 100644 apps/server/src/webrtc/WebRtcIceServerProvider.ts create mode 100644 apps/web/src/components/EnvironmentConnectionStatus.tsx diff --git a/.env.example b/.env.example index fc67dcef9..5d1ed0b24 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,19 @@ T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r # automatically. T3CODE_RELAY_URL=https://relay.t3.codes +# Optional WebRTC ICE configuration. Custom TURN URLs win. Otherwise, setting +# the Cloudflare TURN key ID and API token makes the server mint a short-lived +# credential for every authenticated WebRTC upgrade. Keep real secrets 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 +# T3CODE_WEBRTC_CLOUDFLARE_TURN_KEY_ID=replace-me +# T3CODE_WEBRTC_CLOUDFLARE_TURN_API_TOKEN=replace-me +# Direct WebRTC connections use inbound UDP ports 60000-61000. Allow this +# range through the server host's firewall. + # 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 diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 52f2e6cfc..0a54a1457 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -25,6 +25,7 @@ import { type WebRtcIceServer, WebRtcClientPlatform, WebRtcPeerError, + WebRtcUpgradePreference, } from "@t3tools/websocket-webrtc/peer"; import { AuthStandardClientScopes } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -33,6 +34,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import { AsyncResult, AtomRegistry } from "effect/unstable/reactivity"; import Constants from "expo-constants"; import * as ExpoCrypto from "expo-crypto"; import * as Network from "expo-network"; @@ -43,6 +45,7 @@ import { authClientMetadata } from "../lib/authClientMetadata"; import * as Runtime from "../lib/runtime"; import * as MobileStorage from "../persistence/mobile-storage"; import { appAtomRegistry } from "../state/atom-registry"; +import { mobilePreferencesAtom } from "../state/preferences"; import { clearThreadOutboxEnvironment } from "../state/thread-outbox"; import { clearComposerDraftsEnvironment } from "../state/use-composer-drafts"; import { mobileApplicationActiveWakeup } from "./app-state-wakeups"; @@ -239,8 +242,19 @@ const wakeupsLayer = Wakeups.layer({ (subscription) => Effect.sync(() => subscription.remove()), ).pipe(Effect.asVoid), ), - managedRelayAccountChanges(appAtomRegistry).pipe( - Stream.map(() => "credentials-changed" as const), + Stream.merge( + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), + ), + AtomRegistry.toStream(appAtomRegistry, mobilePreferencesAtom).pipe( + Stream.map( + (preferences) => + !AsyncResult.isSuccess(preferences) || preferences.value.webRtcUpgradeEnabled !== false, + ), + Stream.changes, + Stream.drop(1), + Stream.map(() => "webrtc-preference-changed" as const), + ), ), ), }); @@ -325,6 +339,14 @@ const capabilitiesLayer = Layer.effectContext( }), ), Context.add(WebRtcClientPlatform, mobileWebRtcClientPlatform), + Context.add(WebRtcUpgradePreference, { + isEnabled: Effect.sync(() => { + const preferences = appAtomRegistry.get(mobilePreferencesAtom); + return ( + !AsyncResult.isSuccess(preferences) || preferences.value.webRtcUpgradeEnabled !== false + ); + }), + }), ); }), ); diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 9f2feb1c3..474aa122b 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -127,6 +127,8 @@ function LocalSettingsRouteScreen() { + + @@ -513,6 +515,8 @@ function ConfiguredSettingsRouteScreen() { + + @@ -548,6 +552,26 @@ function GeneralSettingsSection() { ); } +function ExperimentalSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const webRtcUpgradeEnabled = + !AsyncResult.isSuccess(preferencesResult) || + preferencesResult.value.webRtcUpgradeEnabled !== false; + + return ( + + savePreferences({ webRtcUpgradeEnabled: value })} + /> + + ); +} + /** * Device-local legacy toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → General → Legacy features backed by diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 9f231168e..a543e289d 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -43,6 +43,7 @@ export interface Preferences { readonly legacyThreadListEnabled?: boolean; /** Device-local counterpart of desktop's `planModeEnabled` legacy flag. */ readonly planModeEnabled?: boolean; + readonly webRtcUpgradeEnabled?: boolean; } export class MobilePreferencesLoadError extends Schema.TaggedErrorClass()( @@ -102,6 +103,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { progressiveThreadHistoryEnabled?: boolean; legacyThreadListEnabled?: boolean; planModeEnabled?: boolean; + webRtcUpgradeEnabled?: boolean; } = {}; if (typeof parsed.liveActivitiesEnabled === "boolean") { @@ -175,6 +177,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { if (typeof parsed.planModeEnabled === "boolean") { preferences.planModeEnabled = parsed.planModeEnabled; } + if (typeof parsed.webRtcUpgradeEnabled === "boolean") { + preferences.webRtcUpgradeEnabled = parsed.webRtcUpgradeEnabled; + } return preferences; } diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index f46836e03..3b14ea7c1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -21,6 +21,7 @@ import { import { guardHttpResponseWriteErrors } from "./httpResponseErrorGuard.ts"; import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; +import * as WebRtcIceServerProvider from "./webrtc/WebRtcIceServerProvider.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { pullRequestHttpApiLayer } from "./pullRequest/http.ts"; import * as PullRequestProviderRegistry from "./pullRequest/PullRequestProviderRegistry.ts"; @@ -472,7 +473,7 @@ export const makeRoutesLayer = Layer.mergeAll( otlpTracesProxyRouteLayer, assetRouteLayer, staticAndDevRouteLayer, - websocketRpcRouteLayer, + websocketRpcRouteLayer.pipe(Layer.provide(WebRtcIceServerProvider.layer)), ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe( diff --git a/apps/server/src/webrtc/WebRtcIceServerProvider.ts b/apps/server/src/webrtc/WebRtcIceServerProvider.ts new file mode 100644 index 000000000..e58f039bc --- /dev/null +++ b/apps/server/src/webrtc/WebRtcIceServerProvider.ts @@ -0,0 +1,280 @@ +import type { WebRtcIceServer } from "@t3tools/websocket-webrtc/peer"; +import * as Config from "effect/Config"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Redacted from "effect/Redacted"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +const CLOUDFLARE_TURN_API_BASE_URL = "https://rtc.live.cloudflare.com/v1/turn/keys"; +const CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS = 48 * 60 * 60; +const DEFAULT_STUN_URLS = ["stun:stun.cloudflare.com:3478"]; + +const StunUrl = Schema.Trim.check(Schema.isPattern(/^stuns?:/u), Schema.isLengthBetween(1, 2_048)); +const TurnUrl = Schema.Trim.check(Schema.isPattern(/^turns?:/u), Schema.isLengthBetween(1, 2_048)); +const IceCredential = Schema.Trim.check(Schema.isLengthBetween(1, 512)); +const CloudflareTurnKeyId = Schema.Trim.check( + Schema.isPattern(/^[A-Za-z0-9_-]+$/u), + Schema.isLengthBetween(1, 512), +); +const RedactedIceCredential = Schema.Redacted(IceCredential); + +const WebRtcIceServerEnvConfig = Config.all({ + stunUrls: Config.schema(Config.Array(StunUrl), "T3CODE_WEBRTC_STUN_URLS").pipe( + Config.withDefault(DEFAULT_STUN_URLS), + ), + turnUrls: Config.schema(Config.Array(TurnUrl), "T3CODE_WEBRTC_TURN_URLS").pipe(Config.option), + turnUsername: Config.schema(IceCredential, "T3CODE_WEBRTC_TURN_USERNAME").pipe(Config.option), + turnCredential: Config.schema(RedactedIceCredential, "T3CODE_WEBRTC_TURN_CREDENTIAL").pipe( + Config.option, + ), + cloudflareTurnKeyId: Config.schema( + CloudflareTurnKeyId, + "T3CODE_WEBRTC_CLOUDFLARE_TURN_KEY_ID", + ).pipe(Config.option), + cloudflareTurnApiToken: Config.schema( + RedactedIceCredential, + "T3CODE_WEBRTC_CLOUDFLARE_TURN_API_TOKEN", + ).pipe(Config.option), +}); + +const CloudflareTurnCredentialRequest = Schema.Struct({ + ttl: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 48 * 60 * 60 })), +}); + +const CloudflareIceServer = Schema.Struct({ + urls: Schema.Array(Schema.Union([StunUrl, TurnUrl])).check(Schema.isLengthBetween(1, 16)), + username: Schema.optionalKey(IceCredential), + credential: Schema.optionalKey(IceCredential), +}); + +const CloudflareTurnCredentialResponse = Schema.Struct({ + iceServers: Schema.Array(CloudflareIceServer).check(Schema.isLengthBetween(1, 32)), +}); + +const WebRtcIceServerConfigErrorReason = Schema.Literals([ + "turn-credentials-incomplete", + "cloudflare-credentials-incomplete", +]); + +class WebRtcIceServerConfigError extends Schema.TaggedErrorClass()( + "WebRtcIceServerConfigError", + { reason: WebRtcIceServerConfigErrorReason }, +) { + override get message(): string { + return `WebRTC ICE server configuration is invalid: ${this.reason}.`; + } +} + +class CloudflareTurnRequestBodyError extends Schema.TaggedErrorClass()( + "CloudflareTurnRequestBodyError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not encode the Cloudflare TURN credential request."; + } +} + +class CloudflareTurnRequestError extends Schema.TaggedErrorClass()( + "CloudflareTurnRequestError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not send the Cloudflare TURN credential request."; + } +} + +class CloudflareTurnResponseError extends Schema.TaggedErrorClass()( + "CloudflareTurnResponseError", + { status: Schema.Int }, +) { + override get message(): string { + return `Cloudflare TURN credential generation returned HTTP ${this.status}.`; + } +} + +class CloudflareTurnResponseDecodeError extends Schema.TaggedErrorClass()( + "CloudflareTurnResponseDecodeError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Cloudflare returned an invalid TURN credential response."; + } +} + +class CloudflareTurnResponseEmptyError extends Schema.TaggedErrorClass()( + "CloudflareTurnResponseEmptyError", + {}, +) { + override get message(): string { + return "Cloudflare returned no usable TURN servers."; + } +} + +type TurnConfiguration = + | { + readonly kind: "explicit-authenticated"; + readonly urls: ReadonlyArray; + readonly username: string; + readonly credential: Redacted.Redacted; + } + | { readonly kind: "explicit-anonymous"; readonly urls: ReadonlyArray } + | { + readonly kind: "cloudflare"; + readonly keyId: string; + readonly apiToken: Redacted.Redacted; + } + | { readonly kind: "none" }; + +const generateCloudflareTurnServers = Effect.fn( + "WebRtcIceServerProvider.generateCloudflareTurnServers", +)(function* (input: { + readonly configuration: Extract; + readonly httpClient: HttpClient.HttpClient; +}) { + const request = yield* HttpClientRequest.post( + `${CLOUDFLARE_TURN_API_BASE_URL}/${input.configuration.keyId}/credentials/generate-ice-servers`, + ).pipe( + HttpClientRequest.acceptJson, + HttpClientRequest.bearerToken(Redacted.value(input.configuration.apiToken)), + HttpClientRequest.schemaBodyJson(CloudflareTurnCredentialRequest)({ + ttl: CLOUDFLARE_TURN_CREDENTIAL_TTL_SECONDS, + }), + Effect.mapError((cause) => new CloudflareTurnRequestBodyError({ cause })), + ); + const response = yield* input.httpClient + .execute(request) + .pipe(Effect.mapError((cause) => new CloudflareTurnRequestError({ cause }))); + if (response.status !== 201) { + return yield* new CloudflareTurnResponseError({ status: response.status }); + } + const decoded = yield* HttpClientResponse.schemaBodyJson(CloudflareTurnCredentialResponse)( + response, + ).pipe(Effect.mapError((cause) => new CloudflareTurnResponseDecodeError({ cause }))); + const turnServers = decoded.iceServers.flatMap((server) => { + const urls = server.urls.filter((url) => /^turns?:/u.test(url) && !/:53(?:\?|$)/u.test(url)); + if (urls.length === 0 || server.username === undefined || server.credential === undefined) { + return []; + } + return [ + { + urls, + username: server.username, + credential: server.credential, + } satisfies WebRtcIceServer, + ]; + }); + if (turnServers.length === 0) { + return yield* new CloudflareTurnResponseEmptyError(); + } + return turnServers; +}); + +export class WebRtcIceServerProvider extends Context.Service< + WebRtcIceServerProvider, + { + readonly getIceServers: Effect.Effect>; + } +>()("t3/webrtc/WebRtcIceServerProvider") {} + +export const makeWebRtcIceServerProvider = Effect.fn("makeWebRtcIceServerProvider")(function* () { + const config = yield* WebRtcIceServerEnvConfig; + const httpClient = yield* HttpClient.HttpClient; + const turnUrls = Option.getOrElse(config.turnUrls, () => []); + const turnUsername = Option.getOrUndefined(config.turnUsername); + const turnCredential = Option.getOrUndefined(config.turnCredential); + const cloudflareTurnKeyId = Option.getOrUndefined(config.cloudflareTurnKeyId); + const cloudflareTurnApiToken = Option.getOrUndefined(config.cloudflareTurnApiToken); + + if ((turnUsername === undefined) !== (turnCredential === undefined)) { + return yield* new WebRtcIceServerConfigError({ reason: "turn-credentials-incomplete" }); + } + if ((cloudflareTurnKeyId === undefined) !== (cloudflareTurnApiToken === undefined)) { + return yield* new WebRtcIceServerConfigError({ reason: "cloudflare-credentials-incomplete" }); + } + + const stunServers: ReadonlyArray = + config.stunUrls.length === 0 ? [] : [{ urls: config.stunUrls }]; + const turnConfiguration: TurnConfiguration = + turnUrls.length > 0 && turnUsername !== undefined && turnCredential !== undefined + ? { + kind: "explicit-authenticated", + urls: turnUrls, + username: turnUsername, + credential: turnCredential, + } + : turnUrls.length > 0 + ? { kind: "explicit-anonymous", urls: turnUrls } + : cloudflareTurnKeyId !== undefined && cloudflareTurnApiToken !== undefined + ? { + kind: "cloudflare", + keyId: cloudflareTurnKeyId, + apiToken: cloudflareTurnApiToken, + } + : { kind: "none" }; + + const getIceServers: WebRtcIceServerProvider["Service"]["getIceServers"] = (() => { + switch (turnConfiguration.kind) { + case "explicit-authenticated": + return Effect.succeed([ + ...stunServers, + { + urls: turnConfiguration.urls, + username: turnConfiguration.username, + credential: Redacted.value(turnConfiguration.credential), + }, + ]); + case "explicit-anonymous": + return Effect.succeed([...stunServers, { urls: turnConfiguration.urls }]); + case "cloudflare": + return generateCloudflareTurnServers({ configuration: turnConfiguration, httpClient }).pipe( + Effect.timeoutOption("5 seconds"), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.logWarning( + "Cloudflare TURN credential generation timed out; using STUN only.", + ).pipe(Effect.as(stunServers)), + onSome: (turnServers) => Effect.succeed([...stunServers, ...turnServers]), + }), + ), + Effect.catchTags({ + CloudflareTurnRequestBodyError: () => + Effect.logWarning( + "Could not encode the Cloudflare TURN credential request; using STUN only.", + ).pipe(Effect.as(stunServers)), + CloudflareTurnRequestError: () => + Effect.logWarning( + "Could not reach Cloudflare TURN credential generation; using STUN only.", + ).pipe(Effect.as(stunServers)), + CloudflareTurnResponseError: (error) => + Effect.logWarning("Cloudflare TURN credential generation failed; using STUN only.", { + status: error.status, + }).pipe(Effect.as(stunServers)), + CloudflareTurnResponseDecodeError: () => + Effect.logWarning( + "Cloudflare returned an invalid TURN credential response; using STUN only.", + ).pipe(Effect.as(stunServers)), + CloudflareTurnResponseEmptyError: () => + Effect.logWarning("Cloudflare returned no TURN servers; using STUN only.").pipe( + Effect.as(stunServers), + ), + }), + ); + case "none": + return Effect.succeed(stunServers); + default: { + const _exhaustive: never = turnConfiguration; + return _exhaustive; + } + } + })(); + + return WebRtcIceServerProvider.of({ getIceServers }); +}); + +export const layer = Layer.effect(WebRtcIceServerProvider, makeWebRtcIceServerProvider()); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cf350a22b..3c4e33333 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -131,6 +131,7 @@ import * as VcsProcess from "./vcs/VcsProcess.ts"; import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; +import * as WebRtcIceServers from "./webrtc/WebRtcIceServerProvider.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -2393,6 +2394,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const pullRequests = yield* PullRequestService.PullRequestService; + const webRtcIceServers = yield* WebRtcIceServers.WebRtcIceServerProvider; return HttpRouter.add( "GET", "/ws", @@ -2417,11 +2419,13 @@ export const websocketRpcRouteLayer = Layer.unwrap( if (upgradeNonce !== null) { const peerFactory = yield* loadWeriftServerPeerFactory; if (Option.isSome(peerFactory)) { + const iceServers = yield* webRtcIceServers.getIceServers; rpcRequest = mapSocketUpgrade(request, (socket) => makeServerLogicalSocket({ socket, nonce: upgradeNonce, peerFactory: peerFactory.value, + iceServers, }), ); } diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 251b07688..dd77b459b 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -19,7 +19,6 @@ import { sanitizeNewRefName, shouldIncludeBranchPickerItem, shouldShowComposerContextStrip, - shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -385,44 +384,6 @@ describe("resolveEnvironmentOptionLabel", () => { }); }); -describe("shouldShowEnvironmentIndicator", () => { - it("shows the indicator whenever multiple environments are pickable", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: { isPrimary: true }, - canPickEnvironment: true, - }), - ).toBe(true); - }); - - it("shows a sole remote environment so the user knows where the project runs", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: { isPrimary: false }, - canPickEnvironment: false, - }), - ).toBe(true); - }); - - it("hides a sole primary (this-device) environment", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: { isPrimary: true }, - canPickEnvironment: false, - }), - ).toBe(false); - }); - - it("hides the indicator when the active environment is unknown", () => { - expect( - shouldShowEnvironmentIndicator({ - activeEnvironment: null, - canPickEnvironment: false, - }), - ).toBe(false); - }); -}); - describe("shouldShowComposerContextStrip", () => { it("keeps the environment indicator visible for a non-Git project", () => { expect( diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 0a8e07d19..c8e46a2a6 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -43,17 +43,6 @@ export function resolveEnvironmentOptionLabel(input: { return runtimeLabel ?? savedLabel ?? input.environmentId; } -// A remote (non-primary) environment is always surfaced, even when it is the -// only environment available: with a single connected machine there is nothing -// to pick, but the user still needs to see where the project runs. -export function shouldShowEnvironmentIndicator(input: { - activeEnvironment: Pick | null; - canPickEnvironment: boolean; -}): boolean { - if (input.canPickEnvironment) return true; - return input.activeEnvironment !== null && !input.activeEnvironment.isPrimary; -} - export function shouldShowComposerContextStrip(input: { hasActiveProject: boolean; isGitRepo: boolean; diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5d11cce11..808f54469 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,16 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + memo, + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -23,11 +32,11 @@ import { resolveLockedWorkspaceLabel, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, - shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { EnvironmentConnectionStatus } from "./EnvironmentConnectionStatus"; import { Button } from "./ui/button"; import { Menu, @@ -104,43 +113,51 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ ? resolveEnvModeLabel("worktree") : resolveCurrentWorkspaceLabel(activeWorktreePath); const isLocked = envLocked || envModeLocked; - const EnvironmentIcon = activeEnvironment?.isPrimary ? MonitorIcon : CloudIcon; - const icon = showEnvironmentIndicator ? ( - // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px - // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. - - - - - ) : ( - - ); - const triggerContent = ( + const workspaceIndicator = ; + const triggerContent = (indicator: ReactNode) => ( <> - {icon} + {indicator} {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} ); + const lockedTrigger = (indicator: ReactNode) => ( + + {triggerContent(indicator)} + + ); if (isLocked) { + if (!showEnvironmentIndicator) { + return lockedTrigger(workspaceIndicator); + } return ( - - {triggerContent} - + + {lockedTrigger} + ); } + const menuTrigger = (indicator: ReactNode) => ( + } + className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" + > + {triggerContent(indicator)} + + + ); + return ( - } - className="min-w-0 max-w-[48%] flex-1 justify-start text-muted-foreground/70 hover:text-foreground/80 md:hidden" - > - {triggerContent} - - + {showEnvironmentIndicator ? ( + + {menuTrigger} + + ) : ( + menuTrigger(workspaceIndicator) + )} {showEnvironmentPicker && availableEnvironments && onEnvironmentChange ? ( <> @@ -455,10 +472,7 @@ export const BranchToolbar = memo(function BranchToolbar({ ); const activeEnvironmentOption = availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; - const showEnvironmentIndicator = shouldShowEnvironmentIndicator({ - activeEnvironment: activeEnvironmentOption, - canPickEnvironment: showEnvironmentPicker, - }); + const showEnvironmentIndicator = activeEnvironmentOption !== null; const isMobile = useIsMobile(); const [stripElement, setStripElement] = useState(null); const labelsOverflow = useLabelsOverflow(stripElement); diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index b5d5751a2..a95d7580d 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -3,6 +3,7 @@ import { CloudIcon, MonitorIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; +import { EnvironmentConnectionStatus } from "./EnvironmentConnectionStatus"; import { Select, SelectGroup, @@ -17,8 +18,8 @@ interface BranchToolbarEnvironmentSelectorProps { envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; - // Absent when there is only one environment to show: the indicator still - // renders (as a static label) so remote projects are always identifiable. + // Absent when there is only one environment to show: the connection status + // still renders as a static label. onEnvironmentChange?: (environmentId: EnvironmentId) => void; } @@ -48,27 +49,27 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir // only thing in the strip. if (envLocked || onEnvironmentChange === undefined) { return ( - - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} - + + {(indicator) => ( - {activeEnvironment?.label ?? "Run on"} + {indicator} + + + {activeEnvironment?.label ?? "Run on"} + + - - + )} + ); } @@ -79,30 +80,30 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir onValueChange={(value) => onEnvironmentChange(value as EnvironmentId)} items={environmentItems} > - - {activeEnvironment?.isPrimary ? ( - - ) : ( - - )} - - + {(indicator) => ( + - - - - + {indicator} + + + + + + + )} + Run on diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 089a3df0e..33f9cc1b5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -283,7 +283,6 @@ import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, shouldShowComposerContextStrip, - shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, @@ -2002,10 +2001,7 @@ function ChatViewContent(props: ChatViewProps) { logicalProjectEnvironments.find( (environment) => environment.environmentId === activeThread?.environmentId, ) ?? null; - const showComposerEnvironmentIndicator = shouldShowEnvironmentIndicator({ - activeEnvironment: activeEnvironmentOption, - canPickEnvironment: hasMultipleEnvironments, - }); + const showComposerEnvironmentIndicator = activeEnvironmentOption !== null; const openPullRequestDialog = useCallback( (reference?: string) => { diff --git a/apps/web/src/components/EnvironmentConnectionStatus.tsx b/apps/web/src/components/EnvironmentConnectionStatus.tsx new file mode 100644 index 000000000..4cce9af1a --- /dev/null +++ b/apps/web/src/components/EnvironmentConnectionStatus.tsx @@ -0,0 +1,88 @@ +import { connectionStatusText, connectionStatusTitle } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { CableIcon, RadioTowerIcon } from "lucide-react"; +import type { ReactElement, ReactNode } from "react"; +import { useState } from "react"; + +import { useEnvironment } from "~/state/environments"; +import { useEnvironmentRpcRoundTripTime, useEnvironmentRpcTransport } from "~/state/session"; + +import { Badge } from "./ui/badge"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +interface EnvironmentConnectionStatusProps { + readonly environmentId: EnvironmentId; + readonly children: (indicator: ReactNode) => ReactElement; +} + +const ROUND_TRIP_TIME_FORMAT = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 1, +}); + +export function EnvironmentConnectionStatus({ + environmentId, + children, +}: EnvironmentConnectionStatusProps) { + const [tooltipOpen, setTooltipOpen] = useState(false); + const environment = useEnvironment(environmentId); + const transport = useEnvironmentRpcTransport(environmentId); + const phase = environment?.connection.phase ?? "available"; + const roundTripTimeMs = useEnvironmentRpcRoundTripTime( + environmentId, + tooltipOpen && phase === "connected", + ); + const status = environment ? connectionStatusTitle(environment.connection) : "Unavailable"; + const statusDetail = environment ? connectionStatusText(environment.connection) : status; + const transportLabel = + transport === "webrtc" + ? "WebRTC DataChannel" + : transport === "websocket" + ? "WebSocket" + : "Not connected"; + const roundTripTimeLabel = + phase !== "connected" + ? "Unavailable" + : roundTripTimeMs === null + ? "Measuring..." + : `${ROUND_TRIP_TIME_FORMAT.format(roundTripTimeMs)} ms`; + + let variant: "error" | "secondary" | "success" | "warning" = "secondary"; + switch (phase) { + case "connected": + variant = "success"; + break; + case "connecting": + case "reconnecting": + variant = "warning"; + break; + case "error": + variant = "error"; + break; + case "available": + case "offline": + break; + } + + const TransportIcon = transport === "webrtc" ? RadioTowerIcon : CableIcon; + const indicator = ( + + + + ); + + return ( + setTooltipOpen(open)}> + + +
+
Status
+
{statusDetail}
+
RPC transport
+
{transportLabel}
+
RPC RTT
+
{roundTripTimeLabel}
+
+
+
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8553a2f84..98edc2cb2 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2456,6 +2456,31 @@ export function GeneralSettingsPanel() { + + updateSettings({ + webRtcUpgradeEnabled: DEFAULT_UNIFIED_SETTINGS.webRtcUpgradeEnabled, + }) + } + /> + ) : null + } + control={ + + updateSettings({ webRtcUpgradeEnabled: Boolean(checked) }) + } + aria-label="Enable WebRTC transport" + /> + } + /> "credentials-changed" as const), + Stream.merge( + managedRelayAccountChanges(appAtomRegistry).pipe( + Stream.map(() => "credentials-changed" as const), + ), + AtomRegistry.toStream(appAtomRegistry, webRtcUpgradeEnabledAtom).pipe( + Stream.changes, + Stream.drop(1), + Stream.map(() => "webrtc-preference-changed" as const), + ), ), ), }); @@ -382,6 +392,9 @@ const capabilitiesLayer = Layer.effectContext( Context.add(ClientPresentation, presentation), Context.add(SshEnvironmentGateway, ssh), Context.add(WebRtcClientPlatform, webRtcClientPlatform), + Context.add(WebRtcUpgradePreference, { + isEnabled: Effect.sync(() => appAtomRegistry.get(webRtcUpgradeEnabledAtom)), + }), ); }), ); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index a09d0a4df..b69ea11eb 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -27,7 +27,10 @@ import { import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { ensureLocalApi } from "~/localApi"; import { appAtomRegistry } from "~/rpc/atomRegistry"; -import { progressiveThreadHistoryEnabledAtom } from "~/state/clientSettings"; +import { + progressiveThreadHistoryEnabledAtom, + webRtcUpgradeEnabledAtom, +} from "~/state/clientSettings"; import { getThemeDefinition, getThemePreviewSidebarArtwork, @@ -74,6 +77,7 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { progressiveThreadHistoryEnabledAtom, settings.progressiveThreadHistoryEnabled, ); + appAtomRegistry.set(webRtcUpgradeEnabledAtom, settings.webRtcUpgradeEnabled); emitClientSettingsChange(); } @@ -371,6 +375,7 @@ export function __resetClientSettingsPersistenceForTests(): void { progressiveThreadHistoryEnabledAtom, DEFAULT_CLIENT_SETTINGS.progressiveThreadHistoryEnabled, ); + appAtomRegistry.set(webRtcUpgradeEnabledAtom, DEFAULT_CLIENT_SETTINGS.webRtcUpgradeEnabled); clientSettingsHydrated = false; clientSettingsHydrationPromise = null; clientSettingsListeners.clear(); @@ -384,6 +389,7 @@ export function __setClientSettingsForTests(settings: ClientSettings): void { progressiveThreadHistoryEnabledAtom, settings.progressiveThreadHistoryEnabled, ); + appAtomRegistry.set(webRtcUpgradeEnabledAtom, settings.webRtcUpgradeEnabled); clientSettingsHydrated = true; clientSettingsHydrationPromise = null; } diff --git a/apps/web/src/state/clientSettings.ts b/apps/web/src/state/clientSettings.ts index 24cd146e9..763c23caa 100644 --- a/apps/web/src/state/clientSettings.ts +++ b/apps/web/src/state/clientSettings.ts @@ -4,3 +4,8 @@ export const progressiveThreadHistoryEnabledAtom = Atom.make(false).pipe( Atom.keepAlive, Atom.withLabel("web-progressive-thread-history-enabled"), ); + +export const webRtcUpgradeEnabledAtom = Atom.make(true).pipe( + Atom.keepAlive, + Atom.withLabel("web-webrtc-upgrade-enabled"), +); diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index a7d5a53d1..ecbf4ac36 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import type { RpcTransport } from "@t3tools/client-runtime/rpc"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -12,6 +13,12 @@ export const environmentSession = createEnvironmentSessionAtoms(connectionAtomRu const EMPTY_PREPARED_CONNECTION_ATOM = Atom.make(Option.none()).pipe( Atom.withLabel("web-prepared-connection:empty"), ); +const EMPTY_RPC_TRANSPORT_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-rpc-transport:empty"), +); +const EMPTY_RPC_ROUND_TRIP_TIME_ATOM = Atom.make(null).pipe( + Atom.withLabel("web-rpc-round-trip-time:empty"), +); export function usePreparedConnection(environmentId: EnvironmentId | null) { return useAtomValue( @@ -27,6 +34,25 @@ export function readPreparedConnection(environmentId: EnvironmentId) { ); } +export function useEnvironmentRpcTransport(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentId === null + ? EMPTY_RPC_TRANSPORT_ATOM + : environmentSession.rpcTransportValueAtom(environmentId), + ); +} + +export function useEnvironmentRpcRoundTripTime( + environmentId: EnvironmentId | null, + enabled: boolean, +) { + return useAtomValue( + environmentId === null || !enabled + ? EMPTY_RPC_ROUND_TRIP_TIME_ATOM + : environmentSession.rpcRoundTripTimeValueAtom(environmentId), + ); +} + /** * This client's authenticated session on one environment, as reported by that * environment's `/api/auth/session` endpoint. `data` stays populated across diff --git a/docs/internals/websocket-webrtc-upgrade.md b/docs/internals/websocket-webrtc-upgrade.md index 099bbf50b..34cd23e2b 100644 --- a/docs/internals/websocket-webrtc-upgrade.md +++ b/docs/internals/websocket-webrtc-upgrade.md @@ -45,3 +45,37 @@ close still ends the whole logical connection. There is no NACK in version 1. Both physical transports are reliable, so cumulative ACK and replay cover the path-switch case without another recovery mechanism. + +## ICE configuration + +Werift binds direct ICE candidates to UDP ports `60000-61000`. A server host must allow inbound UDP +traffic on that range. Direct connections, including LAN connections, fall back to WebSocket when +the host firewall blocks it. + +The server defaults to `stun:stun.cloudflare.com:3478`. When +`T3CODE_WEBRTC_CLOUDFLARE_TURN_KEY_ID` and +`T3CODE_WEBRTC_CLOUDFLARE_TURN_API_TOKEN` are set, each authenticated WebRTC upgrade gets a fresh +Cloudflare TURN credential. The credential expires after 48 hours. Credential generation stays on +the server; clients only receive the short-lived ICE configuration over their authenticated +WebSocket. + +Custom TURN configuration takes precedence over Cloudflare: + +- `T3CODE_WEBRTC_STUN_URLS` sets a comma-separated STUN URL list. +- `T3CODE_WEBRTC_TURN_URLS` sets a comma-separated TURN URL list. +- `T3CODE_WEBRTC_TURN_USERNAME` and `T3CODE_WEBRTC_TURN_CREDENTIAL` set optional custom TURN + authentication. Set both or neither. + +If Cloudflare credential generation fails or takes longer than five seconds, the server advertises +STUN only. WebRTC can still connect directly; otherwise the logical socket stays on WebSocket. + +## Client controls and diagnostics + +Web and mobile clients enable the upgrade by default. Their Experimental settings include a +client-local `WebRTC transport` switch. Changing it restarts active environment connections so the +next RPC session either attempts the upgrade or stays on WebSocket. + +The web environment connection tooltip shows the selected RPC transport and application-level +round-trip time. Opening the tooltip starts a connection health RPC every two seconds; closing it +stops the probes. The same measurement runs over WebSocket and the WebRTC DataChannel, so the values +are directly comparable. diff --git a/packages/client-runtime/src/connection/supervisor.ts b/packages/client-runtime/src/connection/supervisor.ts index 85fda10ef..126e3cb30 100644 --- a/packages/client-runtime/src/connection/supervisor.ts +++ b/packages/client-runtime/src/connection/supervisor.ts @@ -381,7 +381,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( case "ConnectRequested": break; case "Wakeup": - if (next.reason === "application-active-reconnect") { + if (ConnectionWakeups.shouldRestartConnectionAfterWakeup(next.reason)) { return true; } if (next.reason === "credentials-changed" && target._tag === "RelayConnectionTarget") { @@ -412,10 +412,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( yield* logManagedRelayAccountChange; return false; } - if (next.reason === "application-active-reconnect") { - // Mobile operating systems commonly suspend sockets without - // delivering a close event. A long background resume deliberately - // replaces that lease and starts a fresh attempt without backoff. + if (ConnectionWakeups.shouldRestartConnectionAfterWakeup(next.reason)) { + // Resume recovery and client transport preference changes both + // replace the lease immediately without entering backoff. return true; } if (next.reason === "application-active" || next.reason === "application-active-probe") { @@ -463,7 +462,9 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( } break; case "Wakeup": - if (probeEvent.signal.reason === "application-active-reconnect") { + if ( + ConnectionWakeups.shouldRestartConnectionAfterWakeup(probeEvent.signal.reason) + ) { yield* Fiber.interrupt(probe); return true; } @@ -622,7 +623,7 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const next = yield* Queue.take(signals); switch (next._tag) { case "Wakeup": - return ConnectionWakeups.isApplicationActiveWakeup(next.reason); + return ConnectionWakeups.shouldResetRetryAfterWakeup(next.reason); case "ConnectRequested": case "DisconnectRequested": case "RetryRequested": @@ -636,7 +637,8 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( const waitForSignal = Queue.take(signals).pipe( Effect.map( - (next) => next._tag === "Wakeup" && ConnectionWakeups.isApplicationActiveWakeup(next.reason), + (next) => + next._tag === "Wakeup" && ConnectionWakeups.shouldResetRetryAfterWakeup(next.reason), ), ); diff --git a/packages/client-runtime/src/connection/wakeups.ts b/packages/client-runtime/src/connection/wakeups.ts index 8573a49c1..5c54b4dcf 100644 --- a/packages/client-runtime/src/connection/wakeups.ts +++ b/packages/client-runtime/src/connection/wakeups.ts @@ -6,7 +6,8 @@ export type ConnectionWakeup = | "application-active" | "application-active-probe" | "application-active-reconnect" - | "credentials-changed"; + | "credentials-changed" + | "webrtc-preference-changed"; export function isApplicationActiveWakeup(reason: ConnectionWakeup): boolean { return ( @@ -20,6 +21,14 @@ export function shouldResubscribeAfterWakeup(reason: ConnectionWakeup): boolean return reason === "application-active" || reason === "application-active-probe"; } +export function shouldRestartConnectionAfterWakeup(reason: ConnectionWakeup): boolean { + return reason === "application-active-reconnect" || reason === "webrtc-preference-changed"; +} + +export function shouldResetRetryAfterWakeup(reason: ConnectionWakeup): boolean { + return isApplicationActiveWakeup(reason) || reason === "webrtc-preference-changed"; +} + export class ConnectionWakeups extends Context.Service< ConnectionWakeups, { diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts index 76608388f..b8e01e228 100644 --- a/packages/client-runtime/src/rpc/index.ts +++ b/packages/client-runtime/src/rpc/index.ts @@ -1,4 +1,4 @@ export * from "./client.ts"; export * from "./http.ts"; export * from "./protocol.ts"; -export { type RpcSession, RpcSessionFactory } from "./session.ts"; +export { type RpcSession, RpcSessionFactory, type RpcTransport } from "./session.ts"; diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index a8c3d48f4..f2896bfbd 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -1,6 +1,10 @@ import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; import { makeClientLogicalSocket } from "@t3tools/websocket-webrtc/client"; -import { WebRtcClientPlatform } from "@t3tools/websocket-webrtc/peer"; +import { + WebRtcClientPlatform, + type WebRtcTransportKind, + WebRtcUpgradePreference, +} from "@t3tools/websocket-webrtc/peer"; import { prepareUpgradeUrl } from "@t3tools/websocket-webrtc/wire"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; @@ -8,6 +12,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; @@ -25,12 +31,16 @@ import { const SOCKET_OPEN_TIMEOUT = "15 seconds"; +export type RpcTransport = WebRtcTransportKind; + export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; readonly ready: Effect.Effect; readonly probe: Effect.Effect; readonly closed: Effect.Effect; + readonly transport?: RpcTransport; + readonly transportChanges?: Stream.Stream; } export class RpcSessionFactory extends Context.Service< @@ -71,6 +81,7 @@ function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionA export const make = Effect.gen(function* () { const webSocketConstructor = yield* Socket.WebSocketConstructor; const webRtcPlatform = yield* WebRtcClientPlatform; + const webRtcUpgradePreference = yield* WebRtcUpgradePreference; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { yield* Effect.annotateCurrentSpan({ @@ -79,6 +90,9 @@ export const make = Effect.gen(function* () { const connected = yield* Deferred.make(); const disconnected = yield* Deferred.make(); + const transport = yield* SubscriptionRef.make("websocket"); + const webRtcUpgradeEnabled = + webRtcPlatform === null ? false : yield* webRtcUpgradePreference.isEnabled; const hooks = RpcClient.ConnectionHooks.of({ onConnect: Deferred.succeed(connected, undefined).pipe(Effect.asVoid), onDisconnect: Deferred.isDone(connected).pipe( @@ -97,7 +111,7 @@ export const make = Effect.gen(function* () { ), }); const preparedUpgrade = - webRtcPlatform === null + !webRtcUpgradeEnabled || webRtcPlatform === null ? null : yield* prepareUpgradeUrl(connection.socketUrl, webRtcPlatform).pipe( Effect.catchTags({ @@ -128,6 +142,8 @@ export const make = Effect.gen(function* () { socket, nonce: preparedUpgrade.nonce, peerFactory: webRtcPlatform, + onTransportChange: (nextTransport) => + SubscriptionRef.set(transport, nextTransport), }), ), ), @@ -178,6 +194,10 @@ export const make = Effect.gen(function* () { ), probe, closed: Deferred.await(disconnected), + get transport() { + return SubscriptionRef.getUnsafe(transport); + }, + transportChanges: SubscriptionRef.changes(transport), } satisfies RpcSession; }); diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 31fd297da..cf879e7c3 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,6 +1,8 @@ import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import type { HttpClient } from "effect/unstable/http"; @@ -13,6 +15,7 @@ import { environmentEndpointUrl } from "../environment/endpoint.ts"; import { ManagedRelayDpopSigner } from "../relay/managedRelay.ts"; import { safeErrorLogAttributes } from "../errors/safeLog.ts"; import { executeEnvironmentHttpRequest, makeEnvironmentHttpApiClient } from "../rpc/http.ts"; +import type { RpcTransport } from "../rpc/session.ts"; import { buildEnvironmentAuthHeaders, withEnvironmentCredentials } from "./environmentHttpAuth.ts"; import { followStreamInEnvironment } from "./runtime.ts"; @@ -122,6 +125,75 @@ export function createEnvironmentSessionAtoms( ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), ); + const rpcTransportAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.succeed(null), + onSome: (session) => + session.transportChanges ?? + Stream.succeed(session.transport ?? "websocket"), + }), + ), + ), + ), + ), + ), + ), + { initialValue: null as RpcTransport | null }, + ), + ); + + const rpcTransportValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): RpcTransport | null => + Option.getOrNull(AsyncResult.value(get(rpcTransportAtom(environmentId)))) ?? null, + ).pipe(Atom.withLabel(`environment-rpc-transport:${environmentId}`)), + ); + + const rpcRoundTripTimeAtom = Atom.family((environmentId: EnvironmentId) => + runtime.atom( + followStreamInEnvironment( + environmentId, + Stream.unwrap( + EnvironmentSupervisor.pipe( + Effect.map((supervisor) => + SubscriptionRef.changes(supervisor.session).pipe( + Stream.switchMap( + Option.match({ + onNone: () => Stream.succeed(null), + onSome: (session) => + Stream.fromEffect( + Effect.timed(session.probe).pipe( + Effect.map(([duration]) => Duration.toMillis(duration)), + Effect.option, + Effect.map(Option.getOrNull), + ), + ).pipe(Stream.repeat(Schedule.spaced("2 seconds"))), + }), + ), + ), + ), + ), + ), + ), + { initialValue: null as number | null }, + ), + ); + + const rpcRoundTripTimeValueAtom = Atom.family((environmentId: EnvironmentId) => + Atom.make( + (get): number | null => + Option.getOrNull(AsyncResult.value(get(rpcRoundTripTimeAtom(environmentId)))) ?? null, + ).pipe(Atom.withLabel(`environment-rpc-round-trip-time:${environmentId}`)), + ); + // Keyed on the prepared connection's identity: a reconnect (new credential, // new base URL) swaps the prepared value, which re-runs the fetch, so scope // changes from re-pairing are picked up without an explicit refresh. @@ -156,6 +228,10 @@ export function createEnvironmentSessionAtoms( initialConfigValueAtom, preparedConnectionAtom, preparedConnectionValueAtom, + rpcTransportAtom, + rpcTransportValueAtom, + rpcRoundTripTimeAtom, + rpcRoundTripTimeValueAtom, sessionStateAtom, sessionStateValueAtom, }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 30651a129..8a7ec3b4c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -220,6 +220,7 @@ export const ClientSettingsSchema = Schema.Struct({ progressiveThreadHistoryEnabled: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(false)), ), + webRtcUpgradeEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), // Legacy plan mode. The composer's Build/Plan toggle was removed from the // default UI; this beta flag restores it (plus the /plan and /default slash // commands) for users who still rely on the old workflow. @@ -912,6 +913,7 @@ export const ClientSettingsPatch = Schema.Struct({ ), ), progressiveThreadHistoryEnabled: Schema.optionalKey(Schema.Boolean), + webRtcUpgradeEnabled: Schema.optionalKey(Schema.Boolean), planModeEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), diff --git a/packages/websocket-webrtc/src/client.ts b/packages/websocket-webrtc/src/client.ts index 4b0526d81..d982e75cf 100644 --- a/packages/websocket-webrtc/src/client.ts +++ b/packages/websocket-webrtc/src/client.ts @@ -10,6 +10,7 @@ import { type WebRtcDataChannelPort, type WebRtcIceServer, WebRtcPeerError, + type WebRtcTransportKind, } from "./peer.ts"; import { makeLogicalSocket, type LogicalSocketSession } from "./socket.ts"; import { DATA_CHANNEL_LABEL, type ControlMessage, wireIceServers } from "./wire.ts"; @@ -307,11 +308,15 @@ export function makeClientLogicalSocket(options: { readonly socket: Socket.Socket; readonly nonce: string; readonly peerFactory: ClientWebRtcPeerFactory; + readonly onTransportChange?: (transport: WebRtcTransportKind) => Effect.Effect; }): Socket.Socket { return makeLogicalSocket({ socket: options.socket, nonce: options.nonce, makeDriver: (session) => makeClientDriver(session, options.peerFactory), + ...(options.onTransportChange === undefined + ? {} + : { onTransportChange: options.onTransportChange }), }); } diff --git a/packages/websocket-webrtc/src/peer.ts b/packages/websocket-webrtc/src/peer.ts index 87149e36c..cf087494a 100644 --- a/packages/websocket-webrtc/src/peer.ts +++ b/packages/websocket-webrtc/src/peer.ts @@ -1,5 +1,5 @@ import * as Context from "effect/Context"; -import type * as Effect from "effect/Effect"; +import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; @@ -66,6 +66,12 @@ export class WebRtcClientPlatform extends Context.Reference; +}>("@t3tools/websocket-webrtc/WebRtcUpgradePreference", { + defaultValue: () => ({ isEnabled: Effect.succeed(true) }), +}) {} + export interface ServerWebRtcPeer { readonly acceptOffer: (offerSdp: string) => Effect.Effect; readonly dataChannel: Effect.Effect; diff --git a/packages/websocket-webrtc/src/socket.ts b/packages/websocket-webrtc/src/socket.ts index 1d95e06ce..f8829651f 100644 --- a/packages/websocket-webrtc/src/socket.ts +++ b/packages/websocket-webrtc/src/socket.ts @@ -87,7 +87,7 @@ export interface MakeLogicalSocketOptions { readonly makeDriver: ( session: LogicalSocketSession, ) => Effect.Effect; - readonly onTransportChange?: (transport: WebRtcTransportKind) => void; + readonly onTransportChange?: (transport: WebRtcTransportKind) => Effect.Effect; } function readFailure(cause: unknown): Socket.SocketError { @@ -171,12 +171,12 @@ export function makeLogicalSocket(options: MakeLogicalSocketOptions): Socket.Soc ); const setTransport = (transport: WebRtcTransportKind) => - Effect.sync(() => { + Effect.suspend(() => { if (route === transport) { - return; + return Effect.void; } route = transport; - options.onTransportChange?.(transport); + return options.onTransportChange?.(transport) ?? Effect.void; }); const fallbackLocked = Effect.fn("LogicalWebSocket.fallbackLocked")(function* ( diff --git a/packages/websocket-webrtc/src/werift.ts b/packages/websocket-webrtc/src/werift.ts index 7f0ead4db..ff1dee367 100644 --- a/packages/websocket-webrtc/src/werift.ts +++ b/packages/websocket-webrtc/src/werift.ts @@ -17,6 +17,7 @@ import { const EARLY_MESSAGE_LIMIT = 4; const EARLY_MESSAGE_BYTES_LIMIT = 64 * 1024; const MAX_MESSAGE_SIZE = 16 * 1024; +const ICE_PORT_RANGE: [number, number] = [60_000, 61_000]; type WeriftDataChannel = Pick< RTCDataChannel, @@ -202,6 +203,7 @@ export const loadWeriftServerPeerFactory: Effect.Effect new WebRtcPeerError({ stage: "create", cause }), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91a53beed..1510a1e81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,7 @@ overrides: '@effect/vitest>vitest': '-' '@expo/metro-config': 56.0.14 '@pierre/diffs>@shikijs/transformers': ^4.2.0 + '@peculiar/x509>@peculiar/asn1-schema': 2.9.0 '@types/node': 24.12.4 effect: 4.0.0-beta.103 expo-modules-jsi: 56.0.10 @@ -3657,9 +3658,6 @@ packages: '@peculiar/asn1-rsa@2.9.0': resolution: {integrity: sha512-vOD7Q4UmQWhlMYuWJawS2sD+/JcPKJNtyQuFZx09r+KFhm5/HxfGak63y/m56cpBjmb5k6PeRlQf1fvLQAieUA==} - '@peculiar/asn1-schema@2.8.0': - resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} - '@peculiar/asn1-schema@2.9.0': resolution: {integrity: sha512-AKvPMOM7LfK0uFe1m7o7+veOa8xQGPqsqOrKi3QKgCElzwjGp39mbhr2g7mt/v/mXQHiIvJmDj5cJS173x8Q9Q==} @@ -13834,13 +13832,6 @@ snapshots: tslib: 2.8.1 optional: true - '@peculiar/asn1-schema@2.8.0': - dependencies: - '@peculiar/utils': 2.0.3 - asn1js: 3.0.10 - tslib: 2.8.1 - optional: true - '@peculiar/asn1-schema@2.9.0': dependencies: '@peculiar/utils': 2.0.3 @@ -13886,7 +13877,7 @@ snapshots: '@peculiar/asn1-ecc': 2.9.0 '@peculiar/asn1-pkcs9': 2.9.0 '@peculiar/asn1-rsa': 2.9.0 - '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-schema': 2.9.0 '@peculiar/asn1-x509': 2.9.0 pvtsutils: 1.3.6 reflect-metadata: 0.2.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f7d41b694..11bd2fd2c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -101,6 +101,7 @@ overrides: "@effect/vitest>vitest": "-" "@expo/metro-config": 56.0.14 "@pierre/diffs>@shikijs/transformers": ^4.2.0 + "@peculiar/x509>@peculiar/asn1-schema": 2.9.0 "@types/node": "catalog:" effect: "catalog:" expo-modules-jsi: 56.0.10 From ad2fd22a0dafd014ffd892eeea55c5a27e28baa0 Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 23:07:17 +0300 Subject: [PATCH 4/5] fix(nix): update pnpm dependency hash --- nix/package.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/package.nix b/nix/package.nix index a1d558fc4..2edb43c4f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -48,7 +48,7 @@ stdenv.mkDerivation (finalAttrs: { version = sourceVersion; inherit pnpm; fetcherVersion = 4; - hash = "sha256-cwlzCv1emxDWR+DFSKXZ0pktrTaAFTRjU0ddNBTJk54="; + hash = "sha256-XvDSEJdcmM0yz/43C202DTYzUA9r//FO5g+Xxtm5Lzc="; }; nativeBuildInputs = [ From 803fe2a1a64f5a50cff65f89e959ef18c64bce28 Mon Sep 17 00:00:00 2001 From: Taras Date: Sun, 23 Aug 2026 23:11:15 +0300 Subject: [PATCH 5/5] fix(desktop): update client settings fixture --- apps/desktop/src/settings/DesktopClientSettings.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 20136683f..1480b7972 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -36,6 +36,7 @@ const clientSettings: ClientSettings = { fontSmoothing: true, glassOpacity: 80, progressiveThreadHistoryEnabled: false, + webRtcUpgradeEnabled: true, planModeEnabled: false, providerModelPreferences: {}, sidebarAutoSettleAfterDays: 3,