From 4b9a7b142fa9194692b216f6470a9e302c25c041 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 10 Aug 2026 15:07:09 +0300 Subject: [PATCH 1/9] feat(rpc): add optional WebRTC fast path --- apps/mobile/app.config.ts | 1 + apps/mobile/package.json | 2 + apps/mobile/src/connection/platform.ts | 127 +++++++ apps/server/package.json | 3 + apps/server/src/auth/RpcAuthorization.ts | 2 + apps/server/src/cli/config.test.ts | 29 ++ apps/server/src/cli/config.ts | 45 +++ apps/server/src/config.ts | 4 + apps/server/src/server.test.ts | 112 ++++++ apps/server/src/webrtc/RpcTransport.ts | 5 + apps/server/src/webrtc/SingleSocketServer.ts | 28 ++ .../webrtc/WebRtcFastPathController.test.ts | 328 +++++++++++++++++ .../src/webrtc/WebRtcFastPathController.ts | 315 ++++++++++++++++ apps/server/src/webrtc/WebRtcPeer.test.ts | 151 ++++++++ apps/server/src/webrtc/WebRtcPeer.ts | 199 ++++++++++ apps/server/src/webrtc/config.ts | 22 ++ apps/server/src/ws.ts | 170 ++++++--- apps/web/src/connection/platform.ts | 89 ++++- docs/internals/webrtc-rpc-fast-path.md | 62 ++++ .../src/platform/capabilities.ts | 35 ++ packages/client-runtime/src/rpc/index.ts | 6 + .../client-runtime/src/rpc/session.test.ts | 306 ++++++++++++++- packages/client-runtime/src/rpc/session.ts | 175 +++++++-- .../src/rpc/webrtc/FastPathNegotiator.ts | 238 ++++++++++++ .../src/rpc/webrtc/WebRtcPeer.test.ts | 33 ++ .../src/rpc/webrtc/WebRtcPeer.ts | 177 +++++++++ .../client-runtime/src/rpc/webrtc/cooldown.ts | 28 ++ .../src/state/shell-sync.test.ts | 75 +++- packages/client-runtime/src/state/shell.ts | 5 +- .../src/state/threads-sync.test.ts | 26 +- packages/client-runtime/src/state/threads.ts | 16 +- packages/contracts/src/environment.ts | 4 + packages/contracts/src/index.ts | 1 + packages/contracts/src/rpc.ts | 25 ++ packages/contracts/src/webrtc.ts | 82 +++++ packages/shared/package.json | 16 + .../shared/src/webrtcCandidatePolicy.test.ts | 39 ++ packages/shared/src/webrtcCandidatePolicy.ts | 78 ++++ packages/shared/src/webrtcDataChannel.test.ts | 210 +++++++++++ packages/shared/src/webrtcDataChannel.ts | 243 ++++++++++++ packages/shared/src/webrtcFraming.test.ts | 88 +++++ packages/shared/src/webrtcFraming.ts | 344 +++++++++++++++++ packages/shared/src/webrtcPeerState.test.ts | 24 ++ packages/shared/src/webrtcPeerState.ts | 11 + pnpm-lock.yaml | 347 ++++++++++++++++++ scripts/build-desktop-artifact.ts | 24 +- 46 files changed, 4277 insertions(+), 73 deletions(-) create mode 100644 apps/server/src/webrtc/RpcTransport.ts create mode 100644 apps/server/src/webrtc/SingleSocketServer.ts create mode 100644 apps/server/src/webrtc/WebRtcFastPathController.test.ts create mode 100644 apps/server/src/webrtc/WebRtcFastPathController.ts create mode 100644 apps/server/src/webrtc/WebRtcPeer.test.ts create mode 100644 apps/server/src/webrtc/WebRtcPeer.ts create mode 100644 apps/server/src/webrtc/config.ts create mode 100644 docs/internals/webrtc-rpc-fast-path.md create mode 100644 packages/client-runtime/src/rpc/webrtc/FastPathNegotiator.ts create mode 100644 packages/client-runtime/src/rpc/webrtc/WebRtcPeer.test.ts create mode 100644 packages/client-runtime/src/rpc/webrtc/WebRtcPeer.ts create mode 100644 packages/client-runtime/src/rpc/webrtc/cooldown.ts create mode 100644 packages/contracts/src/webrtc.ts create mode 100644 packages/shared/src/webrtcCandidatePolicy.test.ts create mode 100644 packages/shared/src/webrtcCandidatePolicy.ts create mode 100644 packages/shared/src/webrtcDataChannel.test.ts create mode 100644 packages/shared/src/webrtcDataChannel.ts create mode 100644 packages/shared/src/webrtcFraming.test.ts create mode 100644 packages/shared/src/webrtcFraming.ts create mode 100644 packages/shared/src/webrtcPeerState.test.ts create mode 100644 packages/shared/src/webrtcPeerState.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index c684689fb..c8d827a67 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 8b6834c97..7bab11641 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", @@ -114,6 +115,7 @@ "react-native-screens": "4.25.2", "react-native-shiki-engine": "^0.3.12", "react-native-svg": "15.15.4", + "react-native-webrtc": "^124.0.8", "react-native-webview": "^13.16.1", "react-native-worklets": "0.8.3", "shiki": "4.2.0", diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index a31cfb361..72245a377 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -7,7 +7,14 @@ import { RelayDeviceIdentity, SshEnvironmentGateway, ThreadHistoryCacheStore, + WebRtcPeerFactory, } from "@t3tools/client-runtime/platform"; +import { + makeWebRtcPeerFactory, + selectedIcePairTypeFromStats, + type PlatformWebRtcPeerConnection, + type WebRtcSessionDescription, +} from "@t3tools/client-runtime/rpc"; import { ConnectionBlockedError, ConnectionTransientError, @@ -16,6 +23,7 @@ import { } from "@t3tools/client-runtime/connection"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; import { AuthStandardClientScopes } from "@t3tools/contracts"; +import type { WebRtcDataChannelPort } from "@t3tools/shared/webrtcDataChannel"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -24,6 +32,7 @@ import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as Network from "expo-network"; import { AppState } from "react-native"; +import { RTCPeerConnection, RTCSessionDescription } from "react-native-webrtc"; import { authClientMetadata } from "../lib/authClientMetadata"; import * as Runtime from "../lib/runtime"; @@ -34,6 +43,123 @@ 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: "bufferedamountlow" | "close" | "error" | "open", + listener: () => void, + ): void; + removeEventListener( + type: "message", + listener: (event: MobileDataChannelMessageEvent) => void, + ): void; + removeEventListener( + type: "bufferedamountlow" | "close" | "error" | "open", + listener: () => void, + ): void; +} + +function mobileDataChannelPort(channel: MobileDataChannel): WebRtcDataChannelPort { + // RTCDataChannel inherits the package's EventTarget shim at runtime, but the + // published declaration omits that shim from its generated type artifacts. + const eventChannel = channel as MobileDataChannel & MobileDataChannelEventTarget; + channel.binaryType = "arraybuffer"; + return { + label: channel.label, + ordered: channel.ordered, + isOpen: () => channel.readyState === "open", + bufferedAmount: () => channel.bufferedAmount, + setBufferedAmountLowThreshold: (bytes) => { + channel.bufferedAmountLowThreshold = bytes; + }, + send: (data) => channel.send(data), + close: () => channel.close(), + onOpen: (listener) => { + eventChannel.addEventListener("open", listener); + return () => eventChannel.removeEventListener("open", listener); + }, + onMessage: (listener) => { + const onMessage = (event: MobileDataChannelMessageEvent) => { + if (event.data instanceof ArrayBuffer) { + listener(new Uint8Array(event.data)); + return; + } + if (typeof event.data === "string") { + listener(new TextEncoder().encode(event.data)); + return; + } + void event.data + .arrayBuffer() + .then((buffer: ArrayBuffer) => listener(new Uint8Array(buffer))); + }; + eventChannel.addEventListener("message", onMessage); + return () => eventChannel.removeEventListener("message", onMessage); + }, + onClose: (listener) => { + eventChannel.addEventListener("close", listener); + return () => eventChannel.removeEventListener("close", listener); + }, + onError: (listener) => { + const onError = () => listener(new Error("Mobile WebRTC DataChannel error.")); + eventChannel.addEventListener("error", onError); + return () => eventChannel.removeEventListener("error", onError); + }, + onBufferedAmountLow: (listener) => { + eventChannel.addEventListener("bufferedamountlow", listener); + return () => eventChannel.removeEventListener("bufferedamountlow", listener); + }, + }; +} + +function mobileSessionDescription(description: RTCSessionDescription): WebRtcSessionDescription { + if (description.type !== "offer" && description.type !== "answer") { + throw new Error("Mobile WebRTC returned an invalid session description."); + } + return { type: description.type, sdp: description.sdp }; +} + +function createMobilePeerConnection(stunUrls: ReadonlyArray): PlatformWebRtcPeerConnection { + const peer = new RTCPeerConnection({ + iceServers: stunUrls.map((urls) => ({ urls })), + }); + return { + createDataChannel: (label) => + mobileDataChannelPort(peer.createDataChannel(label, { ordered: true })), + createOffer: () => + peer + .createOffer() + .then((description) => mobileSessionDescription(new RTCSessionDescription(description))), + setLocalDescription: (description) => + peer.setLocalDescription(new RTCSessionDescription(description)), + localDescription: () => + peer.localDescription === null ? null : mobileSessionDescription(peer.localDescription), + setRemoteDescription: (description) => + peer.setRemoteDescription(new RTCSessionDescription(description)), + iceGatheringState: () => peer.iceGatheringState, + onIceGatheringStateChange: (listener) => { + peer.onicegatheringstatechange = listener; + return () => { + peer.onicegatheringstatechange = null; + }; + }, + onConnectionStateChange: (listener) => { + const onStateChange = () => listener(peer.connectionState); + peer.onconnectionstatechange = onStateChange; + return () => { + peer.onconnectionstatechange = null; + }; + }, + selectedIcePairType: () => peer.getStats().then(selectedIcePairTypeFromStats), + close: () => peer.close(), + }; +} + function networkStatus(state: Network.NetworkState): "unknown" | "offline" | "online" { if (state.isConnected === false) { return "offline"; @@ -191,6 +317,7 @@ const capabilitiesLayer = Layer.effectContext( disconnect: () => Effect.void, }), ), + Context.add(WebRtcPeerFactory, makeWebRtcPeerFactory(createMobilePeerConnection)), ); }), ); diff --git a/apps/server/package.json b/apps/server/package.json index a40f9633e..0c0c31ebd 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -47,6 +47,9 @@ "effect-codex-app-server": "workspace:*", "vite-plus": "catalog:" }, + "optionalDependencies": { + "werift": "^0.24.3" + }, "engines": { "node": "^22.16 || ^23.11 || >=24.10" } diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index bcc7b54e8..4d15fbf98 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -32,6 +32,8 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.subscribeThreadWithDelta]: AuthOrchestrationReadScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, + [WS_METHODS.transportWebRtcNegotiate]: AuthOrchestrationReadScope, + [WS_METHODS.transportWebRtcAbort]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index e028577c7..ff5a85b2f 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -19,6 +19,7 @@ import * as NetService from "@t3tools/shared/Net"; import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { deriveServerPaths } from "../config.ts"; +import { parseWebRtcUdpPortRange } from "../webrtc/config.ts"; import { resolveServerConfig } from "./config.ts"; const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => @@ -26,6 +27,12 @@ const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); +it("parses and bounds the WebRTC UDP port range", () => { + expect(parseWebRtcUdpPortRange("60000-61000")).toEqual([60_000, 61_000]); + expect(() => parseWebRtcUdpPortRange("61000-60000")).toThrow(); + expect(() => parseWebRtcUdpPortRange("80-81")).toThrow(); +}); + const makeDesktopBootstrap = ( overrides: Partial = {}, ): DesktopBackendBootstrapValue => ({ @@ -53,6 +60,14 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { otlpServiceName: "t3-server", devAllowedOrigins: [], } as const; + const defaultOptionalRuntimeConfig = { + desktopTelemetryFd: undefined, + desktopTelemetryControlFd: undefined, + resourceMonitorPath: undefined, + webRtcFastPathEnabled: true, + webRtcStunUrls: ["stun:stun.cloudflare.com:3478"], + webRtcUdpPortRange: [60_000, 61_000], + } as const; const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { const fs = yield* FileSystem.FileSystem; const filePath = yield* fs.makeTempFileScoped({ prefix: "t3-bootstrap-", suffix: ".ndjson" }); @@ -106,6 +121,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_NO_BROWSER: "true", T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false", T3CODE_LOG_WS_EVENTS: "true", + T3CODE_WEBRTC_FAST_PATH: "0", + T3CODE_WEBRTC_STUN_URLS: + "stun:stun1.example.test:3478, stuns:stun2.example.test:5349", + T3CODE_WEBRTC_UDP_PORT_RANGE: "62000-62100", }, }), ), @@ -117,6 +136,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Warn", ...defaultObservabilityConfig, + ...defaultOptionalRuntimeConfig, mode: "desktop", port: 4001, cwd: process.cwd(), @@ -134,6 +154,9 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { basePath: ROOT_BASE_PATH, tailscaleServeEnabled: false, tailscaleServePort: 443, + webRtcFastPathEnabled: false, + webRtcStunUrls: ["stun:stun1.example.test:3478", "stuns:stun2.example.test:5349"], + webRtcUdpPortRange: [62_000, 62_100], }); assert.equal(resolved.stateDir, join(baseDir, "userdata")); }), @@ -190,6 +213,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Debug", ...defaultObservabilityConfig, + ...defaultOptionalRuntimeConfig, mode: "web", port: 8788, cwd: process.cwd(), @@ -265,6 +289,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, + ...defaultOptionalRuntimeConfig, mode: "web", port: 8788, cwd: process.cwd(), @@ -341,6 +366,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, + ...defaultOptionalRuntimeConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", mode: "desktop", @@ -479,6 +505,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Debug", ...defaultObservabilityConfig, + ...defaultOptionalRuntimeConfig, mode: "web", port: 8788, cwd: process.cwd(), @@ -548,6 +575,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, + ...defaultOptionalRuntimeConfig, otlpTracesUrl: "http://localhost:4318/v1/traces", otlpMetricsUrl: "http://localhost:4318/v1/metrics", mode: "desktop", @@ -615,6 +643,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { expect(resolved).toEqual({ logLevel: "Info", ...defaultObservabilityConfig, + ...defaultOptionalRuntimeConfig, mode: "web", port: 3773, cwd: process.cwd(), diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 8ecfca59e..9d114a02f 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -14,9 +14,11 @@ import * as SchemaTransformation from "effect/SchemaTransformation"; import { Argument, Flag } from "effect/unstable/cli"; import { normalizeBasePath } from "@t3tools/shared/basePath"; +import { DEFAULT_WEBRTC_STUN_URLS, validateStunUrls } from "@t3tools/shared/webrtcCandidatePolicy"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; +import { DEFAULT_WEBRTC_UDP_PORT_RANGE, parseWebRtcUdpPortRange } from "../webrtc/config.ts"; export const modeFlag = Flag.choice("mode", ServerConfig.RuntimeMode.literals).pipe( Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."), @@ -148,6 +150,46 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + webRtcFastPathEnabled: Config.boolean("T3CODE_WEBRTC_FAST_PATH").pipe(Config.withDefault(true)), + webRtcStunUrls: Config.string("T3CODE_WEBRTC_STUN_URLS").pipe( + Config.withDefault(DEFAULT_WEBRTC_STUN_URLS.join(",")), + Config.map((value) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ), + Config.mapOrFail((urls) => + Effect.try({ + try: () => validateStunUrls(urls), + catch: () => + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: "T3CODE_WEBRTC_STUN_URLS accepts only comma-separated stun: URLs.", + }), + ), + ), + }), + ), + ), + webRtcUdpPortRange: Config.string("T3CODE_WEBRTC_UDP_PORT_RANGE").pipe( + Config.withDefault(DEFAULT_WEBRTC_UDP_PORT_RANGE.join("-")), + Config.mapOrFail((value) => + Effect.try({ + try: () => parseWebRtcUdpPortRange(value), + catch: () => + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: + "T3CODE_WEBRTC_UDP_PORT_RANGE must be two ports from 1024 to 65535 in min-max order.", + }), + ), + ), + }), + ), + ), }); export interface CliServerFlags { @@ -404,6 +446,9 @@ export const resolveServerConfig = ( logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + webRtcFastPathEnabled: env.webRtcFastPathEnabled, + webRtcStunUrls: env.webRtcStunUrls, + webRtcUdpPortRange: env.webRtcUdpPortRange, }; return config; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index bf3dd52ec..492f5ef5f 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -15,6 +15,7 @@ import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; +import type { WebRtcUdpPortRange } from "./webrtc/config.ts"; export const DEFAULT_PORT = 3773; @@ -87,6 +88,9 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + readonly webRtcFastPathEnabled?: boolean; + readonly webRtcStunUrls?: ReadonlyArray; + readonly webRtcUdpPortRange?: WebRtcUdpPortRange; } >()("t3/config/ServerConfig") { /** @deprecated Import and use `layerTest` from this module. */ diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1aa91e60a..d1eb99b2d 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; +import { RTCPeerConnection } from "werift"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { @@ -20,6 +21,7 @@ import { type OrchestrationThreadStreamItem, type OrchestrationThreadShell, TerminalNotRunningError, + WebRtcBindingFrame, type OrchestrationCommand, type OrchestrationEvent, ORCHESTRATION_WS_METHODS, @@ -34,6 +36,7 @@ import { EditorId, } from "@t3tools/contracts"; import { ROOT_BASE_PATH } from "@t3tools/shared/basePath"; +import { makeWebRtcDataChannelConnection } from "@t3tools/shared/webrtcDataChannel"; import { computeDpopAccessTokenHash, computeDpopJwkThumbprint, @@ -79,6 +82,7 @@ const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationThreadDetailSnapshot), ); +const encodeWebRtcBindingFrame = Schema.encodeSync(Schema.fromJsonString(WebRtcBindingFrame)); const collectQueueUntil = Effect.fn("TransferBudget.collectQueueUntil")(function* ( queue: Queue.Queue, @@ -151,6 +155,7 @@ import * as NativeTelemetryClient from "./resourceTelemetry/NativeTelemetryClien import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import { ServerWebRtcPeerError, weriftDataChannelPort } from "./webrtc/WebRtcPeer.ts"; import * as Data from "effect/Data"; import { makeOrchestrationIntegrationHarness } from "../integration/OrchestrationEngineHarness.integration.ts"; @@ -4369,6 +4374,113 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("upgrades an authenticated WebSocket RPC session to WebRTC", () => + Effect.gen(function* () { + yield* buildAppUnderTest({ + config: { + webRtcFastPathEnabled: true, + webRtcStunUrls: [], + }, + }); + + const clientPeer = new RTCPeerConnection({ + iceServers: [], + maxMessageSize: 16 * 1024, + }); + yield* Effect.addFinalizer(() => + Effect.tryPromise({ + try: () => clientPeer.close(), + catch: () => new ServerWebRtcPeerError("connection"), + }).pipe(Effect.ignore), + ); + const rtcClosed = yield* Deferred.make(); + clientPeer.connectionStateChange.subscribe((state) => { + if (state === "closed" || state === "disconnected" || state === "failed") { + Deferred.doneUnsafe(rtcClosed, Effect.void); + } + }); + const dataChannel = clientPeer.createDataChannel("t3-rpc-v1"); + const rtcConnection = yield* makeWebRtcDataChannelConnection( + weriftDataChannelPort(dataChannel), + ); + const wsUrl = yield* getWsServerUrl("/ws"); + + yield* Effect.scoped( + withWsRpcClient(wsUrl, (controlClient) => + Effect.gen(function* () { + const controlConfig = yield* controlClient[WS_METHODS.serverGetConfig]({}); + assert.deepEqual(controlConfig.environment.capabilities.webRtcRpcFastPath, { + version: 1, + signaling: "same-websocket-rpc", + turn: false, + stunUrls: [], + }); + + const offer = yield* Effect.tryPromise({ + try: async () => { + const pendingOffer = await clientPeer.createOffer(); + await clientPeer.setLocalDescription(pendingOffer); + return clientPeer.localDescription; + }, + catch: () => new ServerWebRtcPeerError("offer"), + }); + if (offer === null || offer.type !== "offer") { + return yield* Effect.die(new Error("WebRTC client did not produce an offer.")); + } + const answer = yield* controlClient[WS_METHODS.transportWebRtcNegotiate]({ + version: 1, + attemptId: "authenticated-attempt", + offerSdp: offer.sdp, + }); + yield* Effect.tryPromise({ + try: () => clientPeer.setRemoteDescription({ type: "answer", sdp: answer.answerSdp }), + catch: () => new ServerWebRtcPeerError("offer"), + }); + yield* rtcConnection.awaitOpen.pipe(Effect.timeout("10 seconds")); + yield* rtcConnection.sendBinding( + new TextEncoder().encode( + encodeWebRtcBindingFrame({ + version: 1, + attemptId: answer.attemptId, + bindingToken: answer.bindingToken, + }), + ), + ); + yield* rtcConnection.awaitBindingAck.pipe(Effect.timeout("10 seconds")); + + const rtcProtocolContext = yield* Layer.build( + Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ retryTransientErrors: false }), + ).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Socket.Socket, rtcConnection.socket), + RpcSerialization.layerJson, + ), + ), + ), + ); + const rtcClient = yield* makeWsRpcClient.pipe(Effect.provide(rtcProtocolContext)); + assert.deepEqual(yield* rtcClient[WS_METHODS.serverProbe]({}), {}); + const rtcConfig = yield* rtcClient[WS_METHODS.serverGetConfig]({}); + assert.equal( + rtcConfig.environment.environmentId, + controlConfig.environment.environmentId, + ); + const shellItem = yield* rtcClient[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(shellItem.kind, "snapshot"); + }), + ), + ); + + yield* Deferred.await(rtcClosed).pipe(Effect.timeout("10 seconds")); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc server.upsertKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { diff --git a/apps/server/src/webrtc/RpcTransport.ts b/apps/server/src/webrtc/RpcTransport.ts new file mode 100644 index 000000000..a5fb69277 --- /dev/null +++ b/apps/server/src/webrtc/RpcTransport.ts @@ -0,0 +1,5 @@ +import * as Context from "effect/Context"; + +export type RpcTransportKind = "websocket" | "webrtc"; + +export const RpcTransport = Context.Service("t3/webrtc/RpcTransport"); diff --git a/apps/server/src/webrtc/SingleSocketServer.ts b/apps/server/src/webrtc/SingleSocketServer.ts new file mode 100644 index 000000000..814150d75 --- /dev/null +++ b/apps/server/src/webrtc/SingleSocketServer.ts @@ -0,0 +1,28 @@ +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import type * as Socket from "effect/unstable/socket/Socket"; +import * as SocketServer from "effect/unstable/socket/SocketServer"; + +export interface SingleSocketServer { + readonly server: SocketServer.SocketServer["Service"]; + readonly accept: (socket: Socket.Socket) => Effect.Effect; +} + +export const makeSingleSocketServer = Effect.fn("SingleSocketServer.make")(function* () { + const sockets = yield* Queue.unbounded(); + const server = SocketServer.SocketServer.of({ + address: { _tag: "UnixAddress", path: "webrtc:datachannel" }, + run: (handler) => + Effect.scoped( + Stream.fromQueue(sockets).pipe( + Stream.runForEach((socket) => handler(socket).pipe(Effect.forkScoped, Effect.asVoid)), + Effect.andThen(Effect.never), + ), + ), + }); + return { + server, + accept: (socket) => Queue.offer(sockets, socket).pipe(Effect.asVoid), + } satisfies SingleSocketServer; +}); diff --git a/apps/server/src/webrtc/WebRtcFastPathController.test.ts b/apps/server/src/webrtc/WebRtcFastPathController.test.ts new file mode 100644 index 000000000..65ff2f327 --- /dev/null +++ b/apps/server/src/webrtc/WebRtcFastPathController.test.ts @@ -0,0 +1,328 @@ +import { WebRtcBindingFrame } from "@t3tools/contracts"; +import { + makeWebRtcDataChannelConnection, + type WebRtcDataChannelPort, +} from "@t3tools/shared/webrtcDataChannel"; +import { NodeServices } from "@effect/platform-node"; +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; +import type * as Socket from "effect/unstable/socket/Socket"; + +import { makeSingleSocketServer } from "./SingleSocketServer.ts"; +import { makeWebRtcFastPathController, webRtcAttemptExpired } from "./WebRtcFastPathController.ts"; +import { + type ServerWebRtcPeer, + ServerWebRtcPeerError, + type ServerWebRtcRuntime, +} from "./WebRtcPeer.ts"; + +class TestDataChannelPort implements WebRtcDataChannelPort { + readonly label = "t3-rpc-v1"; + readonly ordered = true; + #open = true; + #peer: TestDataChannelPort | null = null; + #messageListeners = new Set<(data: Uint8Array) => void>(); + #closeListeners = new Set<() => void>(); + #pendingMessages: Array = []; + + connect(peer: TestDataChannelPort): void { + this.#peer = peer; + } + + isOpen(): boolean { + return this.#open; + } + + bufferedAmount(): number { + return 0; + } + + setBufferedAmountLowThreshold(_bytes: number): void {} + + send(data: Uint8Array): void { + if (this.#peer === null) { + return; + } + const copy = data.slice(); + if (this.#peer.#messageListeners.size === 0) { + this.#peer.#pendingMessages.push(copy); + return; + } + for (const listener of this.#peer.#messageListeners) { + listener(copy); + } + } + + close(): void { + if (!this.#open) { + return; + } + this.#open = false; + for (const listener of this.#closeListeners) { + listener(); + } + } + + onOpen(_listener: () => void): () => void { + return () => undefined; + } + + onMessage(listener: (data: Uint8Array) => void): () => void { + this.#messageListeners.add(listener); + for (const message of this.#pendingMessages.splice(0)) { + listener(message); + } + return () => this.#messageListeners.delete(listener); + } + + onClose(listener: () => void): () => void { + this.#closeListeners.add(listener); + return () => this.#closeListeners.delete(listener); + } + + onError(_listener: (error: Error) => void): () => void { + return () => undefined; + } + + onBufferedAmountLow(_listener: () => void): () => void { + return () => undefined; + } +} + +const encodeBinding = Schema.encodeSync(Schema.fromJsonString(WebRtcBindingFrame)); + +const makeHarness = Effect.fn("WebRtcFastPathController.test.makeHarness")(function* ( + attemptTtlMs = 30_000, +) { + const clientPort = new TestDataChannelPort(); + const serverPort = new TestDataChannelPort(); + clientPort.connect(serverPort); + serverPort.connect(clientPort); + const channels = yield* Deferred.make(); + const closeCount = yield* Ref.make(0); + const peer: ServerWebRtcPeer = { + acceptOffer: () => Effect.succeed("v=0\r\n"), + takeDataChannel: Deferred.await(channels), + closed: Effect.never, + diagnosticState: Effect.succeed({ + connectionState: "connected", + gatheringState: "complete", + iceState: "connected", + }), + selectedIcePairType: Effect.succeed("host/host"), + bytesSent: Effect.succeed(0), + bytesReceived: Effect.succeed(0), + close: Ref.update(closeCount, (count) => count + 1).pipe( + Effect.andThen(Effect.sync(() => serverPort.close())), + ), + }; + const runtime: ServerWebRtcRuntime = { + createPeer: () => Effect.succeed(peer), + }; + const socketServer = yield* makeSingleSocketServer(); + const accepted = yield* Queue.unbounded(); + yield* socketServer.server + .run((socket) => Queue.offer(accepted, socket).pipe(Effect.andThen(Effect.never))) + .pipe(Effect.forkScoped); + const controller = yield* makeWebRtcFastPathController({ + enabled: true, + stunUrls: [], + runtime: Option.some(runtime), + socketServer, + attemptTtlMs, + }); + return { + accepted, + channels, + clientPort, + closeCount, + controller, + serverPort, + }; +}); + +describe("WebRtcFastPathController", () => { + it("uses an inclusive attempt deadline", () => { + expect(webRtcAttemptExpired(1_000, 999)).toBe(false); + expect(webRtcAttemptExpired(1_000, 1_000)).toBe(true); + }); + + it.effect("does not advertise a missing optional WebRTC runtime", () => + Effect.gen(function* () { + const controller = yield* makeWebRtcFastPathController({ + enabled: true, + stunUrls: [], + runtime: Option.none(), + socketServer: yield* makeSingleSocketServer(), + }); + + expect(controller.capability).toBeNull(); + const error = yield* Effect.flip( + controller.negotiate({ + version: 1, + attemptId: "attempt-1", + offerSdp: "v=0\r\n", + }), + ); + expect(error._tag).toBe("WebRtcFastPathUnsupportedError"); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("accepts only the binding token issued for the control WebSocket", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const answer = yield* harness.controller.negotiate({ + version: 1, + attemptId: "attempt-1", + offerSdp: "v=0\r\n", + }); + yield* Deferred.succeed(harness.channels, harness.serverPort); + const client = yield* makeWebRtcDataChannelConnection(harness.clientPort); + yield* client.sendBinding( + new TextEncoder().encode( + encodeBinding({ + version: 1, + attemptId: answer.attemptId, + bindingToken: answer.bindingToken, + }), + ), + ); + yield* client.awaitBindingAck; + + expect(yield* Queue.take(harness.accepted)).toBeDefined(); + expect(yield* Ref.get(harness.closeCount)).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("closes a peer whose binding token does not match", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const answer = yield* harness.controller.negotiate({ + version: 1, + attemptId: "attempt-1", + offerSdp: "v=0\r\n", + }); + yield* Deferred.succeed(harness.channels, harness.serverPort); + const client = yield* makeWebRtcDataChannelConnection(harness.clientPort); + yield* client.sendBinding( + new TextEncoder().encode( + encodeBinding({ + version: 1, + attemptId: answer.attemptId, + bindingToken: "wrong-binding-token", + }), + ), + ); + for (let attempt = 0; attempt < 100; attempt += 1) { + if ((yield* Ref.get(harness.closeCount)) > 0) { + break; + } + yield* Effect.yieldNow; + } + + expect(yield* Ref.get(harness.closeCount)).toBe(1); + expect(yield* Queue.size(harness.accepted)).toBe(0); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("expires and cleans up an unbound attempt", () => + Effect.gen(function* () { + const harness = yield* makeHarness(1_000); + yield* harness.controller.negotiate({ + version: 1, + attemptId: "attempt-1", + offerSdp: "v=0\r\n", + }); + + yield* TestClock.adjust("1 second"); + yield* Effect.yieldNow; + + expect(yield* Ref.get(harness.closeCount)).toBe(1); + }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), + ); + + it.effect("closes a partially initialized peer when offer negotiation fails", () => + Effect.gen(function* () { + const closeCount = yield* Ref.make(0); + const peer: ServerWebRtcPeer = { + acceptOffer: () => Effect.fail(new ServerWebRtcPeerError("offer")), + takeDataChannel: Effect.never, + closed: Effect.never, + diagnosticState: Effect.succeed({ + connectionState: "new", + gatheringState: "new", + iceState: "new", + }), + selectedIcePairType: Effect.succeed(null), + bytesSent: Effect.succeed(0), + bytesReceived: Effect.succeed(0), + close: Ref.update(closeCount, (count) => count + 1), + }; + const controller = yield* makeWebRtcFastPathController({ + enabled: true, + stunUrls: [], + runtime: Option.some({ createPeer: () => Effect.succeed(peer) }), + socketServer: yield* makeSingleSocketServer(), + }); + + const error = yield* Effect.flip( + controller.negotiate({ + version: 1, + attemptId: "attempt-1", + offerSdp: "v=0\r\n", + }), + ); + + expect(error._tag).toBe("WebRtcFastPathNegotiationError"); + expect(yield* Ref.get(closeCount)).toBe(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.effect("closes a partially initialized peer when negotiation is interrupted", () => + Effect.gen(function* () { + const offerStarted = yield* Deferred.make(); + const closeCount = yield* Ref.make(0); + const peer: ServerWebRtcPeer = { + acceptOffer: () => + Deferred.succeed(offerStarted, undefined).pipe(Effect.andThen(Effect.never)), + takeDataChannel: Effect.never, + closed: Effect.never, + diagnosticState: Effect.succeed({ + connectionState: "new", + gatheringState: "new", + iceState: "new", + }), + selectedIcePairType: Effect.succeed(null), + bytesSent: Effect.succeed(0), + bytesReceived: Effect.succeed(0), + close: Ref.update(closeCount, (count) => count + 1), + }; + const controller = yield* makeWebRtcFastPathController({ + enabled: true, + stunUrls: [], + runtime: Option.some({ createPeer: () => Effect.succeed(peer) }), + socketServer: yield* makeSingleSocketServer(), + }); + const negotiation = yield* controller + .negotiate({ + version: 1, + attemptId: "attempt-1", + offerSdp: "v=0\r\n", + }) + .pipe(Effect.forkChild); + + yield* Deferred.await(offerStarted); + yield* Fiber.interrupt(negotiation); + + expect(yield* Ref.get(closeCount)).toBe(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/webrtc/WebRtcFastPathController.ts b/apps/server/src/webrtc/WebRtcFastPathController.ts new file mode 100644 index 000000000..218a75d8d --- /dev/null +++ b/apps/server/src/webrtc/WebRtcFastPathController.ts @@ -0,0 +1,315 @@ +import { + WebRtcBindingFrame, + WebRtcFastPathBusyError, + WebRtcFastPathDisabledError, + WebRtcFastPathInvalidAttemptError, + WebRtcFastPathInvalidSdpError, + WebRtcFastPathNegotiationError, + WebRtcFastPathUnsupportedError, + type WebRtcNegotiateInput, + type WebRtcNegotiateResult, + type WebRtcRpcFastPathCapability, + type WebRtcSignalingError, +} from "@t3tools/contracts"; +import { + validateSessionDescription, + validateStunUrls, +} from "@t3tools/shared/webrtcCandidatePolicy"; +import { makeWebRtcDataChannelConnection } from "@t3tools/shared/webrtcDataChannel"; +import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; + +import type { SingleSocketServer } from "./SingleSocketServer.ts"; +import type { ServerWebRtcPeer, ServerWebRtcRuntime } from "./WebRtcPeer.ts"; +import { DEFAULT_WEBRTC_UDP_PORT_RANGE, type WebRtcUdpPortRange } from "./config.ts"; + +const DEFAULT_ATTEMPT_TTL_MS = 30_000; +const RATE_LIMIT_WINDOW_MS = 60_000; +const RATE_LIMIT_ATTEMPTS = 5; + +const decodeBindingFrame = Schema.decodeUnknownEffect(Schema.fromJsonString(WebRtcBindingFrame)); + +interface ActiveAttempt { + readonly attemptId: string; + readonly bindingToken: string; + readonly expiresAtMs: number; + readonly peer: ServerWebRtcPeer; + readonly bound: boolean; +} + +export interface WebRtcFastPathController { + readonly capability: WebRtcRpcFastPathCapability | null; + readonly negotiate: ( + input: WebRtcNegotiateInput, + ) => Effect.Effect; + readonly abort: (attemptId: string) => Effect.Effect<{}, WebRtcSignalingError>; + readonly close: Effect.Effect; +} + +export interface WebRtcFastPathControllerOptions { + readonly enabled: boolean; + readonly stunUrls: ReadonlyArray; + readonly runtime: Option.Option; + readonly socketServer: SingleSocketServer; + readonly udpPortRange?: WebRtcUdpPortRange; + readonly attemptTtlMs?: number; +} + +export function webRtcAttemptExpired(expiresAtMs: number, nowMs: number): boolean { + return nowMs >= expiresAtMs; +} + +export const makeWebRtcFastPathController = Effect.fn("WebRtcFastPathController.make")(function* ( + options: WebRtcFastPathControllerOptions, +) { + const scope = yield* Scope.Scope; + const crypto = yield* Crypto.Crypto; + const negotiationLock = yield* Semaphore.make(1); + const active = yield* Ref.make(null); + const attemptTimes = yield* Ref.make>([]); + const attemptTtlMs = options.attemptTtlMs ?? DEFAULT_ATTEMPT_TTL_MS; + const runtime = Option.getOrNull(options.runtime); + const stunUrls = yield* Effect.try({ + try: () => validateStunUrls(options.stunUrls), + catch: () => null, + }); + const capability: WebRtcRpcFastPathCapability | null = + options.enabled && runtime !== null && stunUrls !== null + ? { + version: 1, + signaling: "same-websocket-rpc", + turn: false, + stunUrls: [...stunUrls], + } + : null; + + const closeAttempt = (attempt: ActiveAttempt | null) => + attempt === null ? Effect.void : attempt.peer.close; + const clearAttempt = Effect.fn("WebRtcFastPathController.clearAttempt")(function* ( + attemptId: string, + ) { + const attempt = yield* Ref.modify(active, (current) => + current?.attemptId === attemptId ? [current, null] : [null, current], + ); + yield* closeAttempt(attempt); + }); + + const handleBinding = Effect.fn("WebRtcFastPathController.handleBinding")(function* ( + attempt: ActiveAttempt, + ) { + const port = yield* attempt.peer.takeDataChannel; + if (port.label !== "t3-rpc-v1" || !port.ordered) { + return yield* new WebRtcFastPathInvalidAttemptError({ + message: "WebRTC DataChannel does not match the negotiated RPC transport.", + }); + } + const connection = yield* makeWebRtcDataChannelConnection(port); + yield* connection.awaitOpen; + const bindingBytes = yield* connection.awaitBinding; + const binding = yield* decodeBindingFrame(new TextDecoder().decode(bindingBytes)).pipe( + Effect.mapError( + () => + new WebRtcFastPathInvalidAttemptError({ + message: "WebRTC binding frame is invalid.", + }), + ), + ); + const nowMs = yield* Clock.currentTimeMillis; + const current = yield* Ref.get(active); + if ( + current === null || + current.bound || + current.attemptId !== attempt.attemptId || + binding.attemptId !== attempt.attemptId || + binding.bindingToken !== attempt.bindingToken || + webRtcAttemptExpired(attempt.expiresAtMs, nowMs) + ) { + return yield* new WebRtcFastPathInvalidAttemptError({ + message: "WebRTC binding attempt is invalid or expired.", + }); + } + yield* Ref.set(active, { ...attempt, bound: true }); + yield* connection.sendBindingAck; + yield* options.socketServer.accept(connection.socket); + yield* Effect.logDebug("WebRTC RPC DataChannel bound.").pipe( + Effect.annotateLogs({ + "rpc.transport": "webrtc", + "webrtc.attempt.result": "selected", + }), + ); + const logStats = Effect.all({ + bytesSent: attempt.peer.bytesSent, + bytesReceived: attempt.peer.bytesReceived, + selectedIcePairType: attempt.peer.selectedIcePairType, + }).pipe( + Effect.flatMap((stats) => + Effect.logDebug("WebRTC RPC DataChannel closed.").pipe( + Effect.annotateLogs({ + "rpc.transport": "webrtc", + "webrtc.bytes_sent": stats.bytesSent, + "webrtc.bytes_received": stats.bytesReceived, + ...(stats.selectedIcePairType === null + ? {} + : { "webrtc.selected_ice_pair_type": stats.selectedIcePairType }), + }), + ), + ), + ); + return yield* Effect.raceFirst(attempt.peer.closed, connection.closed).pipe( + Effect.ensuring(logStats), + ); + }); + + const negotiateUnlocked = Effect.fn("WebRtcFastPathController.negotiateUnlocked")(function* ( + input: WebRtcNegotiateInput, + ) { + if (!options.enabled) { + return yield* new WebRtcFastPathDisabledError({ message: "WebRTC fast path is disabled." }); + } + if (runtime === null || capability === null || stunUrls === null) { + return yield* new WebRtcFastPathUnsupportedError({ + message: "WebRTC fast path is unavailable on this server.", + }); + } + yield* Effect.try({ + try: () => validateSessionDescription(input.offerSdp), + catch: () => new WebRtcFastPathInvalidSdpError({ message: "WebRTC offer SDP is invalid." }), + }); + const nowMs = yield* Clock.currentTimeMillis; + const recentAttempts = (yield* Ref.get(attemptTimes)).filter( + (attemptedAtMs) => nowMs - attemptedAtMs < RATE_LIMIT_WINDOW_MS, + ); + if (recentAttempts.length >= RATE_LIMIT_ATTEMPTS) { + return yield* new WebRtcFastPathBusyError({ + message: "WebRTC negotiation rate limit reached.", + }); + } + yield* Ref.set(attemptTimes, [...recentAttempts, nowMs]); + const previous = yield* Ref.getAndSet(active, null); + yield* closeAttempt(previous); + const peer = yield* runtime + .createPeer(input.attemptId, stunUrls, options.udpPortRange ?? DEFAULT_WEBRTC_UDP_PORT_RANGE) + .pipe( + Effect.mapError( + () => + new WebRtcFastPathNegotiationError({ + message: "WebRTC peer initialization failed.", + }), + ), + ); + return yield* Effect.gen(function* () { + const answerSdp = yield* peer + .acceptOffer(input.offerSdp) + .pipe( + Effect.mapError( + () => + new WebRtcFastPathNegotiationError({ message: "WebRTC offer negotiation failed." }), + ), + ); + yield* Effect.try({ + try: () => validateSessionDescription(answerSdp), + catch: () => + new WebRtcFastPathNegotiationError({ + message: "WebRTC generated an invalid answer.", + }), + }); + const bindingToken = yield* crypto.randomBytes(32).pipe( + Effect.map(Encoding.encodeBase64Url), + Effect.mapError( + () => + new WebRtcFastPathNegotiationError({ + message: "WebRTC binding token generation failed.", + }), + ), + ); + const bindingStartedAtMs = yield* Clock.currentTimeMillis; + const expiresAtMs = bindingStartedAtMs + attemptTtlMs; + const attempt: ActiveAttempt = { + attemptId: input.attemptId, + bindingToken, + expiresAtMs, + peer, + bound: false, + }; + yield* Ref.set(active, attempt); + const bindingDeadline = Effect.sleep(Duration.millis(attemptTtlMs)).pipe( + Effect.andThen(Ref.get(active)), + Effect.flatMap((current) => + current?.attemptId === attempt.attemptId && current.bound === false + ? Effect.fail( + new WebRtcFastPathInvalidAttemptError({ + message: "WebRTC binding attempt expired.", + }), + ) + : Effect.never, + ), + ); + yield* Effect.forkIn( + Effect.scoped(handleBinding(attempt)).pipe( + Effect.raceFirst(bindingDeadline), + Effect.tapError(() => clearAttempt(attempt.attemptId)), + Effect.ignore, + ), + scope, + ); + return { + version: 1, + attemptId: input.attemptId, + answerSdp, + bindingToken, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMs)), + } satisfies WebRtcNegotiateResult; + }).pipe(Effect.onError(() => peer.close.pipe(Effect.andThen(clearAttempt(input.attemptId))))); + }); + + const negotiate = Effect.fn("WebRtcFastPathController.negotiate")(function* ( + input: WebRtcNegotiateInput, + ) { + const result = yield* negotiationLock.withPermitsIfAvailable(1)(negotiateUnlocked(input)); + if (Option.isNone(result)) { + return yield* new WebRtcFastPathBusyError({ + message: "Another WebRTC negotiation is already active.", + }); + } + return result.value; + }); + + const abort = Effect.fn("WebRtcFastPathController.abort")(function* (attemptId: string) { + const current = yield* Ref.get(active); + if (current === null || current.attemptId !== attemptId) { + return yield* new WebRtcFastPathInvalidAttemptError({ + message: "WebRTC attempt is invalid or expired.", + }); + } + const diagnosticState = yield* current.peer.diagnosticState; + yield* Effect.logInfo("WebRTC negotiation aborted by the client.").pipe( + Effect.annotateLogs({ + "webrtc.attempt.result": "aborted", + "webrtc.peer.connection_state": diagnosticState.connectionState, + "webrtc.peer.gathering_state": diagnosticState.gatheringState, + "webrtc.peer.ice_state": diagnosticState.iceState, + }), + ); + yield* clearAttempt(attemptId); + return {}; + }); + + const close = Ref.getAndSet(active, null).pipe(Effect.flatMap(closeAttempt)); + yield* Effect.addFinalizer(() => close); + + return { + capability, + negotiate, + abort, + close, + } satisfies WebRtcFastPathController; +}); diff --git a/apps/server/src/webrtc/WebRtcPeer.test.ts b/apps/server/src/webrtc/WebRtcPeer.test.ts new file mode 100644 index 000000000..9c3a3bdcb --- /dev/null +++ b/apps/server/src/webrtc/WebRtcPeer.test.ts @@ -0,0 +1,151 @@ +import { WebRtcBindingFrame } from "@t3tools/contracts"; +import { makeWebRtcDataChannelConnection } from "@t3tools/shared/webrtcDataChannel"; +import { NodeServices } from "@effect/platform-node"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as Rpc from "effect/unstable/rpc/Rpc"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcGroup from "effect/unstable/rpc/RpcGroup"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as RpcServer from "effect/unstable/rpc/RpcServer"; +import * as Socket from "effect/unstable/socket/Socket"; +import * as SocketServer from "effect/unstable/socket/SocketServer"; +import { RTCPeerConnection } from "werift"; + +import { makeSingleSocketServer } from "./SingleSocketServer.ts"; +import { makeWebRtcFastPathController } from "./WebRtcFastPathController.ts"; +import { + loadServerWebRtcRuntime, + ServerWebRtcPeerError, + weriftDataChannelPort, +} from "./WebRtcPeer.ts"; + +const encodeBinding = Schema.encodeSync(Schema.fromJsonString(WebRtcBindingFrame)); +const ProbeRpc = Rpc.make("Probe", { + payload: Schema.Struct({}), + success: Schema.Struct({}), +}); +const LargeRpc = Rpc.make("Large", { + payload: Schema.Struct({ size: Schema.Number }), + success: Schema.String, +}); +const EventsRpc = Rpc.make("Events", { + payload: Schema.Struct({}), + success: Schema.String, + stream: true, +}); +const NativeRpcGroup = RpcGroup.make(ProbeRpc, LargeRpc, EventsRpc); +const NativeRpcHandlers = NativeRpcGroup.toLayer({ + Probe: () => Effect.succeed({}), + Large: ({ size }) => Effect.succeed("x".repeat(size)), + Events: () => Stream.make("first", "second", "third"), +}); + +function withPeerTimeout( + effect: Effect.Effect, + stage: string, +): Effect.Effect { + return effect.pipe( + Effect.timeoutOrElse({ + duration: "10 seconds", + orElse: () => Effect.die(new Error(`WebRTC timed out during ${stage}.`)), + }), + ); +} + +it.effect("binds and fragments RPC messages over a real DataChannel", () => + Effect.gen(function* () { + const runtime = Option.getOrThrow(yield* loadServerWebRtcRuntime); + const socketServer = yield* makeSingleSocketServer(); + yield* Layer.build( + RpcServer.layer(NativeRpcGroup).pipe( + Layer.provide(NativeRpcHandlers), + Layer.provide(RpcServer.layerProtocolSocketServer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(SocketServer.SocketServer, socketServer.server), + RpcSerialization.layerJson, + ), + ), + ), + ); + const controller = yield* makeWebRtcFastPathController({ + enabled: true, + stunUrls: [], + runtime: Option.some(runtime), + socketServer, + }); + + const clientPeer = new RTCPeerConnection({ + iceServers: [], + maxMessageSize: 16 * 1024, + }); + yield* Effect.addFinalizer(() => + Effect.tryPromise({ + try: () => clientPeer.close(), + catch: () => new ServerWebRtcPeerError("connection"), + }).pipe(Effect.ignore), + ); + const clientChannel = clientPeer.createDataChannel("t3-rpc-v1"); + const clientConnection = yield* makeWebRtcDataChannelConnection( + weriftDataChannelPort(clientChannel), + ); + const offer = yield* Effect.tryPromise({ + try: async () => { + const pendingOffer = await clientPeer.createOffer(); + await clientPeer.setLocalDescription(pendingOffer); + return clientPeer.localDescription; + }, + catch: () => new ServerWebRtcPeerError("offer"), + }); + if (offer === null || offer.type !== "offer") { + return yield* Effect.die(new Error("WebRTC did not create an offer.")); + } + const answer = yield* controller.negotiate({ + version: 1, + attemptId: "native-attempt", + offerSdp: offer.sdp, + }); + yield* Effect.tryPromise({ + try: () => clientPeer.setRemoteDescription({ type: "answer", sdp: answer.answerSdp }), + catch: () => new ServerWebRtcPeerError("offer"), + }); + yield* withPeerTimeout(clientConnection.awaitOpen, "DataChannel open"); + yield* clientConnection.sendBinding( + new TextEncoder().encode( + encodeBinding({ + version: 1, + attemptId: answer.attemptId, + bindingToken: answer.bindingToken, + }), + ), + ); + yield* withPeerTimeout(clientConnection.awaitBindingAck, "binding"); + + const protocolContext = yield* Layer.build( + Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ retryTransientErrors: false }), + ).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Socket.Socket, clientConnection.socket), + RpcSerialization.layerJson, + ), + ), + ), + ); + const client = yield* RpcClient.make(NativeRpcGroup).pipe(Effect.provide(protocolContext)); + expect(yield* withPeerTimeout(client.Probe({}), "probe RPC")).toEqual({}); + expect(yield* withPeerTimeout(client.Large({ size: 40_000 }), "large RPC")).toHaveLength( + 40_000, + ); + expect( + yield* withPeerTimeout(client.Events({}).pipe(Stream.runCollect), "streaming RPC"), + ).toEqual(["first", "second", "third"]); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/webrtc/WebRtcPeer.ts b/apps/server/src/webrtc/WebRtcPeer.ts new file mode 100644 index 000000000..a500d0dfc --- /dev/null +++ b/apps/server/src/webrtc/WebRtcPeer.ts @@ -0,0 +1,199 @@ +import * as NodeBuffer from "node:buffer"; + +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 { WebRtcDataChannelPort } from "@t3tools/shared/webrtcDataChannel"; +import { isTerminalWebRtcPeerConnectionState } from "@t3tools/shared/webrtcPeerState"; + +import type { WebRtcUdpPortRange } from "./config.ts"; + +export interface ServerWebRtcPeer { + readonly acceptOffer: (offerSdp: string) => Effect.Effect; + readonly takeDataChannel: Effect.Effect; + readonly closed: Effect.Effect; + readonly diagnosticState: Effect.Effect<{ + readonly connectionState: string; + readonly gatheringState: string; + readonly iceState: string; + }>; + readonly selectedIcePairType: Effect.Effect; + readonly bytesSent: Effect.Effect; + readonly bytesReceived: Effect.Effect; + readonly close: Effect.Effect; +} + +export class ServerWebRtcPeerError extends Error { + readonly stage: "create" | "offer" | "connection"; + + constructor(stage: "create" | "offer" | "connection") { + super(`Server WebRTC peer failed during ${stage}.`); + this.name = "ServerWebRtcPeerError"; + this.stage = stage; + } +} + +export interface ServerWebRtcRuntime { + readonly createPeer: ( + attemptId: string, + stunUrls: ReadonlyArray, + udpPortRange: WebRtcUdpPortRange, + ) => Effect.Effect; +} + +function iceCandidateType(candidate: string): string { + const match = /(?:^|\s)typ\s+(host|srflx|prflx|relay)(?:\s|$)/i.exec(candidate); + return match?.[1]?.toLowerCase() ?? "unknown"; +} + +export function weriftDataChannelPort(channel: RTCDataChannel): WebRtcDataChannelPort { + return { + label: channel.label, + ordered: channel.ordered, + isOpen: () => channel.readyState === "open", + bufferedAmount: () => channel.bufferedAmount, + setBufferedAmountLowThreshold: (bytes) => { + channel.bufferedAmountLowThreshold = bytes; + }, + send: (data) => channel.send(NodeBuffer.Buffer.from(data)), + close: () => channel.close(), + onOpen: (listener) => { + const subscription = channel.stateChanged.subscribe((state) => { + if (state === "open") listener(); + }); + return subscription.unSubscribe; + }, + onMessage: (listener) => { + const subscription = channel.onMessage.subscribe((message) => { + if (typeof message === "string") { + listener(new TextEncoder().encode(message)); + return; + } + listener(Uint8Array.from(message)); + }); + return subscription.unSubscribe; + }, + onClose: (listener) => { + const subscription = channel.stateChanged.subscribe((state) => { + if (state === "closed") listener(); + }); + return subscription.unSubscribe; + }, + onError: (listener) => { + const subscription = channel.error.subscribe(listener); + return subscription.unSubscribe; + }, + onBufferedAmountLow: (listener) => { + const subscription = channel.bufferedAmountLow.subscribe(listener); + return subscription.unSubscribe; + }, + }; +} + +function makeWeriftPeer(connection: RTCPeerConnection): Effect.Effect { + return Effect.gen(function* () { + const closed = yield* Deferred.make(); + const channels = yield* Queue.unbounded(); + const dataChannels: Array = []; + const stateSubscription = connection.connectionStateChange.subscribe((state) => { + if (isTerminalWebRtcPeerConnectionState(state)) { + Deferred.doneUnsafe(closed, Effect.fail(new ServerWebRtcPeerError("connection"))); + } + }); + const dataChannelSubscription = connection.onDataChannel.subscribe((channel) => { + dataChannels.push(channel); + Queue.offerUnsafe(channels, weriftDataChannelPort(channel)); + }); + + const acceptOffer = Effect.fn("ServerWebRtcPeer.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: () => new ServerWebRtcPeerError("offer"), + }).pipe(Effect.raceFirst(Deferred.await(closed))); + const answer = connection.localDescription; + if (answer === null || answer.type !== "answer") { + return yield* Effect.fail(new ServerWebRtcPeerError("offer")); + } + return answer.sdp; + }); + + const close = Effect.tryPromise({ + try: () => connection.close(), + catch: () => new ServerWebRtcPeerError("connection"), + }).pipe( + Effect.ensuring( + Effect.sync(() => { + stateSubscription.unSubscribe(); + dataChannelSubscription.unSubscribe(); + }), + ), + Effect.ignore, + ); + + return { + acceptOffer, + takeDataChannel: Queue.take(channels).pipe( + Effect.mapError(() => new ServerWebRtcPeerError("connection")), + Effect.raceFirst(Deferred.await(closed)), + ), + closed: Deferred.await(closed), + diagnosticState: Effect.sync(() => ({ + connectionState: connection.connectionState, + gatheringState: connection.iceGatheringState, + iceState: connection.iceConnectionState, + })), + selectedIcePairType: Effect.sync(() => { + for (const transport of connection.iceTransports) { + const pair = transport.getSelectedCandidatePair(); + if (pair !== null) { + return `${iceCandidateType(pair.local.candidate)}/${iceCandidateType(pair.remote.candidate)}`; + } + } + return null; + }), + bytesSent: Effect.sync(() => + dataChannels.reduce((total, channel) => total + channel.bytesSent, 0), + ), + bytesReceived: Effect.sync(() => + dataChannels.reduce((total, channel) => total + channel.bytesReceived, 0), + ), + close, + } satisfies ServerWebRtcPeer; + }); +} + +export const loadServerWebRtcRuntime: Effect.Effect> = + Effect.tryPromise({ + try: () => import("werift"), + catch: () => new ServerWebRtcPeerError("create"), + }).pipe( + Effect.map( + (werift) => + ({ + createPeer: Effect.fn("ServerWebRtcRuntime.createPeer")(function* ( + _attemptId: string, + stunUrls: ReadonlyArray, + udpPortRange: WebRtcUdpPortRange, + ) { + const connection = yield* Effect.try({ + try: () => + new werift.RTCPeerConnection({ + iceServers: stunUrls.map((urls) => ({ urls })), + icePortRange: [...udpPortRange], + maxMessageSize: 16 * 1024, + }), + catch: () => new ServerWebRtcPeerError("create"), + }); + return yield* makeWeriftPeer(connection); + }), + }) satisfies ServerWebRtcRuntime, + ), + Effect.option, + ); diff --git a/apps/server/src/webrtc/config.ts b/apps/server/src/webrtc/config.ts new file mode 100644 index 000000000..cad77ea18 --- /dev/null +++ b/apps/server/src/webrtc/config.ts @@ -0,0 +1,22 @@ +export type WebRtcUdpPortRange = readonly [minimum: number, maximum: number]; + +export const DEFAULT_WEBRTC_UDP_PORT_RANGE: WebRtcUdpPortRange = [60_000, 61_000]; + +export function parseWebRtcUdpPortRange(value: string): WebRtcUdpPortRange { + const match = /^(\d+)-(\d+)$/.exec(value.trim()); + if (match === null) { + throw new Error("WebRTC UDP port range must use min-max syntax."); + } + const minimum = Number(match[1]); + const maximum = Number(match[2]); + if ( + !Number.isInteger(minimum) || + !Number.isInteger(maximum) || + minimum < 1_024 || + maximum > 65_535 || + minimum >= maximum + ) { + throw new Error("WebRTC UDP port range is invalid."); + } + return [minimum, maximum]; +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1878c3c49..3716f35c1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -56,12 +56,14 @@ import { type TerminalError, type TerminalEvent, type TerminalMetadataStreamEvent, + WebRtcFastPathUnsupportedError, WS_METHODS, WsRpcGroup, } from "@t3tools/contracts"; import { resolveServerBackgroundActivitySettings } from "@t3tools/shared/backgroundActivitySettings"; import { HttpRouter, HttpServerRequest, HttpServerRespondable } from "effect/unstable/http"; import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; +import * as SocketServer from "effect/unstable/socket/SocketServer"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as ServerConfig from "./config.ts"; @@ -122,6 +124,13 @@ 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 { + makeWebRtcFastPathController, + type WebRtcFastPathController, +} from "./webrtc/WebRtcFastPathController.ts"; +import { loadServerWebRtcRuntime } from "./webrtc/WebRtcPeer.ts"; +import { RpcTransport, type RpcTransportKind } from "./webrtc/RpcTransport.ts"; +import { makeSingleSocketServer } from "./webrtc/SingleSocketServer.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -345,9 +354,11 @@ function toAuthAccessStreamEvent( const makeWsRpcLayer = ( currentSession: EnvironmentAuth.AuthenticatedSession, previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"], + webRtc: WebRtcFastPathController, ) => WsRpcGroup.toLayer( Effect.gen(function* () { + const rpcTransport = yield* RpcTransport; const currentSessionId = currentSession.sessionId; const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; @@ -434,21 +445,19 @@ const makeWsRpcLayer = ( effect: Effect.Effect, traceAttributes?: Readonly>, ) => - instrumentRpcEffect( - method, - authorizeEffect(requiredScopeForRpcMethod(method), effect), - traceAttributes, - ); + instrumentRpcEffect(method, authorizeEffect(requiredScopeForRpcMethod(method), effect), { + ...traceAttributes, + "rpc.transport": rpcTransport, + }); const observeRpcStream = ( method: string, stream: Stream.Stream, traceAttributes?: Readonly>, ) => - instrumentRpcStream( - method, - authorizeStream(requiredScopeForRpcMethod(method), stream), - traceAttributes, - ); + instrumentRpcStream(method, authorizeStream(requiredScopeForRpcMethod(method), stream), { + ...traceAttributes, + "rpc.transport": rpcTransport, + }); const observeRpcStreamEffect = ( method: string, effect: Effect.Effect< @@ -461,7 +470,7 @@ const makeWsRpcLayer = ( instrumentRpcStreamEffect( method, authorizeEffect(requiredScopeForRpcMethod(method), effect), - traceAttributes, + { ...traceAttributes, "rpc.transport": rpcTransport }, ); const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => isOrchestrationDispatchCommandError(cause) @@ -1110,7 +1119,13 @@ const makeWsRpcLayer = ( const auth = yield* serverAuth.getDescriptor(); return { - environment, + environment: { + ...environment, + capabilities: { + ...environment.capabilities, + ...(webRtc.capability === null ? {} : { webRtcRpcFastPath: webRtc.capability }), + }, + }, auth, cwd: config.cwd, keybindingsConfigPath: config.keybindingsConfigPath, @@ -1441,7 +1456,38 @@ const makeWsRpcLayer = ( [WS_METHODS.serverGetConfig]: (_input) => observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { "rpc.aggregate": "server", + "rpc.transport": rpcTransport, }), + [WS_METHODS.transportWebRtcNegotiate]: (input) => + observeRpcEffect( + WS_METHODS.transportWebRtcNegotiate, + rpcTransport === "websocket" + ? webRtc.negotiate(input) + : Effect.fail( + new WebRtcFastPathUnsupportedError({ + message: "WebRTC signaling is only available through the control WebSocket.", + }), + ), + { + "rpc.aggregate": "transport", + "rpc.transport": rpcTransport, + }, + ), + [WS_METHODS.transportWebRtcAbort]: (input) => + observeRpcEffect( + WS_METHODS.transportWebRtcAbort, + rpcTransport === "websocket" + ? webRtc.abort(input.attemptId) + : Effect.fail( + new WebRtcFastPathUnsupportedError({ + message: "WebRTC signaling is only available through the control WebSocket.", + }), + ), + { + "rpc.aggregate": "transport", + "rpc.transport": rpcTransport, + }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -2201,6 +2247,38 @@ const makeWsRpcLayer = ( }), ); +const makeWsRpcHandlerLayer = (options: { + readonly session: EnvironmentAuth.AuthenticatedSession; + readonly previewAutomationBroker: PreviewAutomationBroker.PreviewAutomationBroker["Service"]; + readonly serverSelfUpdate: ServerSelfUpdate.ServerSelfUpdate["Service"]; + readonly webRtc: WebRtcFastPathController; + readonly transport: RpcTransportKind; +}) => + makeWsRpcLayer(options.session, options.previewAutomationBroker, options.webRtc).pipe( + Layer.provide(Layer.succeed(RpcTransport, options.transport)), + Layer.provide(ProviderMaintenanceRunner.layer), + Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, options.serverSelfUpdate)), + Layer.provide( + SourceControlDiscovery.layer.pipe( + Layer.provide( + SourceControlProviderRegistry.layer.pipe( + Layer.provide( + Layer.mergeAll( + AzureDevOpsCli.layer, + BitbucketApi.layer, + GitHubCli.layer, + GitLabCli.layer, + ), + ), + Layer.provideMerge(GitVcsDriver.layer), + Layer.provide(VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer))), + ), + ), + Layer.provide(VcsProcess.layer), + ), + ), + ); + export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; @@ -2212,6 +2290,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( const request = yield* HttpServerRequest.HttpServerRequest; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const config = yield* ServerConfig.ServerConfig; const session = yield* serverAuth.authenticateWebSocketUpgrade(request).pipe( Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => failEnvironmentAuthInvalid(EnvironmentAuth.serverAuthCredentialReason(error)), @@ -2220,42 +2299,51 @@ export const websocketRpcRouteLayer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - disableTracing: true, - }).pipe( - Effect.provide( - makeWsRpcLayer(session, previewAutomationBroker).pipe( - Layer.provideMerge(RpcSerialization.layerJson), - Layer.provide(ProviderMaintenanceRunner.layer), - Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), - Layer.provide( - SourceControlDiscovery.layer.pipe( - Layer.provide( - SourceControlProviderRegistry.layer.pipe( - Layer.provide( - Layer.mergeAll( - AzureDevOpsCli.layer, - BitbucketApi.layer, - GitHubCli.layer, - GitLabCli.layer, - ), - ), - Layer.provideMerge(GitVcsDriver.layer), - Layer.provide( - VcsDriverRegistry.layer.pipe(Layer.provide(VcsProjectConfig.layer)), - ), - ), - ), - Layer.provide(VcsProcess.layer), - ), + const socketServer = yield* makeSingleSocketServer(); + const webRtcRuntime = + config.webRtcFastPathEnabled === true ? yield* loadServerWebRtcRuntime : Option.none(); + const webRtc = yield* makeWebRtcFastPathController({ + enabled: config.webRtcFastPathEnabled === true, + stunUrls: config.webRtcStunUrls ?? [], + ...(config.webRtcUdpPortRange === undefined + ? {} + : { udpPortRange: config.webRtcUdpPortRange }), + runtime: webRtcRuntime, + socketServer, + }); + const websocketHandlers = makeWsRpcHandlerLayer({ + session, + previewAutomationBroker, + serverSelfUpdate, + webRtc, + transport: "websocket", + }); + const webRtcHandlers = makeWsRpcHandlerLayer({ + session, + previewAutomationBroker, + serverSelfUpdate, + webRtc, + transport: "webrtc", + }); + yield* Layer.build( + RpcServer.layer(WsRpcGroup, { disableTracing: true }).pipe( + Layer.provide(webRtcHandlers), + Layer.provide(RpcServer.layerProtocolSocketServer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(SocketServer.SocketServer, socketServer.server), + RpcSerialization.layerJson, ), ), ), ); + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { + disableTracing: true, + }).pipe(Effect.provide(Layer.mergeAll(websocketHandlers, RpcSerialization.layerJson))); return yield* Effect.acquireUseRelease( sessions.markConnected(session.sessionId), () => rpcWebSocketHttpEffect, - () => sessions.markDisconnected(session.sessionId), + () => webRtc.close.pipe(Effect.andThen(sessions.markDisconnected(session.sessionId))), ); }).pipe( Effect.catchTags({ diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index c7652136f..6ba696767 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -6,6 +6,7 @@ import { PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, + WebRtcPeerFactory, } from "@t3tools/client-runtime/platform"; import { BearerConnectionCredential, @@ -24,7 +25,14 @@ import { import { bootstrapRemoteBearerSession } from "@t3tools/client-runtime/authorization"; import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; -import { EnvironmentRpcRequestObserver } from "@t3tools/client-runtime/rpc"; +import { + EnvironmentRpcRequestObserver, + makeWebRtcPeerFactory, + selectedIcePairTypeFromStats, + type PlatformWebRtcPeerConnection, + type WebRtcSessionDescription, +} from "@t3tools/client-runtime/rpc"; +import type { WebRtcDataChannelPort } from "@t3tools/shared/webrtcDataChannel"; import { AuthStandardClientScopes, type DesktopBridge, @@ -61,6 +69,84 @@ 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, + setBufferedAmountLowThreshold: (bytes) => { + channel.bufferedAmountLowThreshold = bytes; + }, + 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); + }, + onBufferedAmountLow: (listener) => { + channel.addEventListener("bufferedamountlow", listener); + return () => channel.removeEventListener("bufferedamountlow", listener); + }, + }; +} + +function webSessionDescription( + description: RTCSessionDescription | RTCSessionDescriptionInit, +): WebRtcSessionDescription { + if ( + (description.type !== "offer" && description.type !== "answer") || + description.sdp === undefined + ) { + throw new Error("Browser WebRTC returned an invalid session description."); + } + return { type: description.type, sdp: description.sdp }; +} + +function createWebPeerConnection(stunUrls: ReadonlyArray): PlatformWebRtcPeerConnection { + const peer = new RTCPeerConnection({ + iceServers: stunUrls.map((urls) => ({ urls })), + }); + 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); + }, + selectedIcePairType: () => peer.getStats().then(selectedIcePairTypeFromStats), + close: () => peer.close(), + }; +} + function currentNetworkStatus(): "unknown" | "offline" | "online" { if (typeof navigator === "undefined") { return "unknown"; @@ -279,6 +365,7 @@ const capabilitiesLayer = Layer.effectContext( Context.add(RelayDeviceIdentity, identity), Context.add(ClientPresentation, presentation), Context.add(SshEnvironmentGateway, ssh), + Context.add(WebRtcPeerFactory, makeWebRtcPeerFactory(createWebPeerConnection)), ); }), ); diff --git a/docs/internals/webrtc-rpc-fast-path.md b/docs/internals/webrtc-rpc-fast-path.md new file mode 100644 index 000000000..4099e9f92 --- /dev/null +++ b/docs/internals/webrtc-rpc-fast-path.md @@ -0,0 +1,62 @@ +# WebRTC RPC fast path + +T3 Code can run `WsRpcGroup` over one reliable, ordered WebRTC DataChannel named +`t3-rpc-v1`. This is an optional transport upgrade, not a separate connection +mode. + +The client first establishes and authenticates the normal environment WebSocket. +It reads `server.getConfig` there and negotiates WebRTC through +`transport.webrtc.negotiate` on that same socket. The server binds the new +DataChannel to the authenticated WebSocket with a short-lived attempt ID and a +one-time random token. The client selects WebRTC only after `server.probe` and a +matching `server.getConfig` succeed over the DataChannel. + +The control WebSocket remains open and authoritative for authentication, +signaling, presence, and lifetime. Signaling calls made over WebRTC are rejected. +If the control WebSocket closes, the server and client close its DataChannel. If +a selected DataChannel closes, the normal environment supervisor replaces the +whole session. It does not replay requests or move in-flight streams between +transports. + +## ICE policy and support + +The server uses `werift`, a pure TypeScript WebRTC implementation that ships in +the existing Node and desktop bundles without a native artifact. Web and desktop +renderers use the browser WebRTC implementation. Mobile uses +`react-native-webrtc` through its Expo config plugin. + +Only direct host, peer-reflexive, and server-reflexive ICE candidates are +accepted. Configuration rejects `turn:` and `turns:` URLs, and both peers reject +SDP containing `typ relay`. There is no TURN service or TURN fallback. Symmetric +NATs, restrictive firewalls, blocked UDP, and some enterprise networks can +therefore prevent the fast path. T3 Code silently keeps using the already-open +WebSocket in those cases. + +The upgrade runs after any connection target has produced an authenticated +environment WebSocket. Relay, managed endpoint, manual bearer, SSH-forwarded, +web, desktop, and mobile connections use the same path. + +## Configuration + +- `T3CODE_WEBRTC_FAST_PATH=0` disables the server capability. +- `T3CODE_WEBRTC_STUN_URLS` is a comma-separated STUN-only list. The default is + `stun:stun.cloudflare.com:3478`. An empty list permits host candidates only. +- `T3CODE_WEBRTC_UDP_PORT_RANGE` sets the server candidate range as `min-max`. + The default is `60000-61000`. Host firewalls must allow inbound UDP on this + range for direct and server-reflexive candidates to work. + +The server advertises the capability only when it is enabled, the STUN list is +valid, and the optional runtime loads. A missing runtime never prevents +WebSocket startup. + +## Diagnostics + +Debug traces annotate RPC work with `rpc.transport=websocket|webrtc`. WebRTC +attempts add `webrtc.attempt.result`, `webrtc.fallback.reason`, negotiation and +DataChannel-open durations, byte counters, and the selected candidate pair types +when the platform exposes them. Logs never contain SDP, candidate strings, IP +addresses, DTLS fingerprints, binding tokens, or authentication tokens. + +When WebRTC is selected, initial shell and thread snapshots come from their +existing socket subscriptions. HTTP remains in use for WebSocket sessions and +for older thread pages. diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index a20b7d404..42b7e4396 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -8,6 +8,10 @@ import { import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; import type * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +import type { WebRtcDataChannelPort } from "@t3tools/shared/webrtcDataChannel"; import type { ConnectionAttemptError } from "../connection/model.ts"; @@ -66,3 +70,34 @@ export class SshEnvironmentGateway extends Context.Service< ) => Effect.Effect; } >()("@t3tools/client-runtime/platform/capabilities/SshEnvironmentGateway") {} + +export class WebRtcPeerError extends Schema.TaggedErrorClass()("WebRtcPeerError", { + stage: Schema.Literals(["create", "offer", "answer", "ice-gathering", "connection", "stats"]), + cause: Schema.Defect(), +}) { + override get message(): string { + return `WebRTC peer failed during ${this.stage}.`; + } +} + +export interface WebRtcPeer { + readonly dataChannel: WebRtcDataChannelPort; + readonly createOffer: Effect.Effect; + readonly acceptAnswer: (answerSdp: string) => Effect.Effect; + readonly closed: Effect.Effect; + readonly selectedIcePairType: Effect.Effect; + readonly close: Effect.Effect; +} + +export interface WebRtcPeerFactoryService { + readonly create: ( + stunUrls: ReadonlyArray, + ) => Effect.Effect; +} + +export class WebRtcPeerFactory extends Context.Reference( + "@t3tools/client-runtime/platform/capabilities/WebRtcPeerFactory", + { + defaultValue: () => null, + }, +) {} diff --git a/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts index 76608388f..e755d9bed 100644 --- a/packages/client-runtime/src/rpc/index.ts +++ b/packages/client-runtime/src/rpc/index.ts @@ -2,3 +2,9 @@ export * from "./client.ts"; export * from "./http.ts"; export * from "./protocol.ts"; export { type RpcSession, RpcSessionFactory } from "./session.ts"; +export { + makeWebRtcPeerFactory, + selectedIcePairTypeFromStats, + type PlatformWebRtcPeerConnection, + type WebRtcSessionDescription, +} from "./webrtc/WebRtcPeer.ts"; diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 0af5850bf..b8e34df46 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -7,17 +7,29 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import * as Socket from "effect/unstable/socket/Socket"; +import { + makeWebRtcDataChannelConnection, + type WebRtcDataChannelPort, +} from "@t3tools/shared/webrtcDataChannel"; import { ConnectionTransientError, PrimaryConnectionTarget, type PreparedConnection, } from "../connection/model.ts"; +import { + WebRtcPeerError, + WebRtcPeerFactory, + type WebRtcPeer, + type WebRtcPeerFactoryService, +} from "../platform/capabilities.ts"; import * as RpcSession from "./session.ts"; type SocketEventType = "open" | "message" | "close" | "error"; @@ -133,6 +145,22 @@ const SERVER_CONFIG: ServerConfigType = { settings: DEFAULT_SERVER_SETTINGS, }; +const WEBRTC_SERVER_CONFIG: ServerConfigType = { + ...SERVER_CONFIG, + environment: { + ...SERVER_CONFIG.environment, + capabilities: { + ...SERVER_CONFIG.environment.capabilities, + webRtcRpcFastPath: { + version: 1, + signaling: "same-websocket-rpc", + turn: false, + stunUrls: [], + }, + }, + }, +}; + const RpcRequest = Schema.TaggedStruct("Request", { id: Schema.Union([Schema.String, Schema.Number]), payload: Schema.Unknown, @@ -140,9 +168,13 @@ const RpcRequest = Schema.TaggedStruct("Request", { }); const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const decodeRpcRequest = Schema.decodeUnknownSync(RpcRequest); +const decodeWebRtcNegotiatePayload = Schema.decodeUnknownSync( + Schema.Struct({ attemptId: Schema.String }), +); const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const encodeServerConfig = Schema.encodeSync(ServerConfig); const ENCODED_SERVER_CONFIG = encodeServerConfig(SERVER_CONFIG); +const ENCODED_WEBRTC_SERVER_CONFIG = encodeServerConfig(WEBRTC_SERVER_CONFIG); const LEGACY_SERVER_CONFIG = { ...ENCODED_SERVER_CONFIG, environment: { @@ -153,18 +185,165 @@ const LEGACY_SERVER_CONFIG = { }, }; -const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* () { +const makeFactory = Effect.fn("TestRpcSessionFactory.make")(function* ( + webRtcPeerFactory: WebRtcPeerFactoryService | null = null, +) { const sockets: TestWebSocket[] = []; const constructorLayer = Layer.succeed(Socket.WebSocketConstructor, (url) => { const socket = new TestWebSocket(url); sockets.push(socket); return socket as unknown as globalThis.WebSocket; }); - const layer = RpcSession.layer.pipe(Layer.provide(constructorLayer)); + const layer = RpcSession.layer.pipe( + Layer.provide( + Layer.mergeAll(constructorLayer, Layer.succeed(WebRtcPeerFactory, webRtcPeerFactory)), + ), + ); const factory = yield* RpcSession.RpcSessionFactory.pipe(Effect.provide(layer)); return { factory, sockets }; }); +class TestDataChannelPort implements WebRtcDataChannelPort { + readonly label = "t3-rpc-v1"; + readonly ordered = true; + #open = true; + #peer: TestDataChannelPort | null = null; + #messageListeners = new Set<(data: Uint8Array) => void>(); + #closeListeners = new Set<() => void>(); + + connect(peer: TestDataChannelPort): void { + this.#peer = peer; + } + + isOpen(): boolean { + return this.#open; + } + + bufferedAmount(): number { + return 0; + } + + setBufferedAmountLowThreshold(_bytes: number): void {} + + send(data: Uint8Array): void { + if (this.#peer === null) { + return; + } + const copy = data.slice(); + for (const listener of this.#peer.#messageListeners) { + listener(copy); + } + } + + close(): void { + if (!this.#open) { + return; + } + this.#open = false; + for (const listener of this.#closeListeners) { + listener(); + } + } + + onOpen(_listener: () => void): () => void { + return () => undefined; + } + + onMessage(listener: (data: Uint8Array) => void): () => void { + this.#messageListeners.add(listener); + return () => this.#messageListeners.delete(listener); + } + + onClose(listener: () => void): () => void { + this.#closeListeners.add(listener); + return () => this.#closeListeners.delete(listener); + } + + onError(_listener: (error: Error) => void): () => void { + return () => undefined; + } + + onBufferedAmountLow(_listener: () => void): () => void { + return () => undefined; + } +} + +const makeRtcHarness = Effect.fn("TestRpcSessionFactory.makeRtcHarness")(function* () { + const clientPort = new TestDataChannelPort(); + const serverPort = new TestDataChannelPort(); + clientPort.connect(serverPort); + serverPort.connect(clientPort); + const peerClosed = yield* Deferred.make(); + const created = yield* Queue.unbounded(); + const rtcRequests = yield* Queue.unbounded(); + const peer: WebRtcPeer = { + dataChannel: clientPort, + createOffer: Effect.succeed("v=0\r\n"), + acceptAnswer: () => Effect.void, + closed: Deferred.await(peerClosed), + selectedIcePairType: Effect.succeed("host/host"), + close: Effect.sync(() => clientPort.close()), + }; + const peerFactory: WebRtcPeerFactoryService = { + create: () => Queue.offer(created, undefined).pipe(Effect.as(peer)), + }; + const serverConnection = yield* makeWebRtcDataChannelConnection(serverPort); + yield* Effect.gen(function* () { + yield* serverConnection.awaitBinding; + yield* serverConnection.sendBindingAck; + const writer = yield* serverConnection.socket.writer; + yield* serverConnection.socket.runString((message) => { + const request = decodeRpcRequest(decodeJson(message)); + return Queue.offer(rtcRequests, request.tag).pipe( + Effect.andThen( + writer( + encodeJson({ + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Success", + value: + request.tag === WS_METHODS.serverGetConfig ? ENCODED_WEBRTC_SERVER_CONFIG : {}, + }, + }), + ), + ), + ); + }); + }).pipe(Effect.forkScoped); + return { + clientPort, + peerClosed, + peerFactory, + created, + rtcRequests, + }; +}); + +const completeWebRtcSignaling = Effect.fn("TestRpcSessionFactory.completeWebRtcSignaling")( + function* (socket: TestWebSocket) { + const request = yield* awaitRequest(socket, 1); + const payload = decodeWebRtcNegotiatePayload(request.payload); + expect(request.tag).toBe(WS_METHODS.transportWebRtcNegotiate); + socket.serverMessage( + encodeJson({ + _tag: "Exit", + requestId: request.id, + exit: { + _tag: "Success", + value: { + version: 1, + attemptId: payload.attemptId, + answerSdp: "v=0\r\n", + bindingToken: "binding-token", + expiresAt: "2099-08-09T00:00:00.000Z", + }, + }, + }), + ); + }, +); + const awaitSocket = Effect.fn("TestRpcSessionFactory.awaitSocket")(function* ( sockets: ReadonlyArray, ) { @@ -215,6 +394,129 @@ const completeInitialConfig = Effect.fn("TestRpcSessionFactory.completeInitialCo }); describe("RpcSessionFactory", () => { + it.effect("stays on WebSocket when the server capability is absent", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + + socket.open(); + yield* completeInitialConfig(socket); + yield* Fiber.join(readyFiber); + + expect(session.transport).toBe("websocket"); + expect(socket.sent).toHaveLength(1); + }), + ); + + it.effect("stays on WebSocket when the platform has no WebRTC adapter", () => + Effect.gen(function* () { + const { factory, sockets } = yield* makeFactory(); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + + socket.open(); + yield* completeInitialConfig(socket, ENCODED_WEBRTC_SERVER_CONFIG); + yield* Fiber.join(readyFiber); + + expect(session.transport).toBe("websocket"); + expect(socket.sent).toHaveLength(1); + }), + ); + + it.effect("silently falls back when WebRTC peer creation fails", () => + Effect.gen(function* () { + const peerFactory: WebRtcPeerFactoryService = { + create: () => + Effect.fail( + new WebRtcPeerError({ + stage: "create", + cause: new Error("WebRTC unavailable in test."), + }), + ), + }; + const { factory, sockets } = yield* makeFactory(peerFactory); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + + socket.open(); + yield* completeInitialConfig(socket, ENCODED_WEBRTC_SERVER_CONFIG); + yield* Fiber.join(readyFiber); + + expect(session.transport).toBe("websocket"); + expect(socket.sent).toHaveLength(1); + }), + ); + + it.effect("selects WebRTC after its RPC probe and closes it with the control WebSocket", () => + Effect.gen(function* () { + const rtc = yield* makeRtcHarness(); + const { factory, sockets } = yield* makeFactory(rtc.peerFactory); + const session = yield* factory.connect(PREPARED); + const readyFiber = yield* Effect.forkChild(session.ready); + const socket = yield* awaitSocket(sockets); + + socket.open(); + yield* completeInitialConfig(socket, ENCODED_WEBRTC_SERVER_CONFIG); + yield* completeWebRtcSignaling(socket); + yield* Fiber.join(readyFiber); + + expect(session.transport).toBe("webrtc"); + expect(yield* Queue.take(rtc.rtcRequests)).toBe(WS_METHODS.serverProbe); + expect(yield* Queue.take(rtc.rtcRequests)).toBe(WS_METHODS.serverGetConfig); + expect(socket.sent.map((value) => decodeRpcRequest(decodeJson(value)).tag)).toEqual([ + WS_METHODS.serverGetConfig, + WS_METHODS.transportWebRtcNegotiate, + ]); + + const closedFiber = yield* Effect.flip(session.closed).pipe(Effect.forkChild); + socket.close(1012, "service restart"); + yield* Fiber.join(closedFiber); + expect(rtc.clientPort.isOpen()).toBe(false); + }), + ); + + it.effect("fails after selected WebRTC closes and cools down the next attempt", () => + Effect.gen(function* () { + const rtc = yield* makeRtcHarness(); + const { factory, sockets } = yield* makeFactory(rtc.peerFactory); + const firstSession = yield* factory.connect(PREPARED); + const firstReady = yield* Effect.forkChild(firstSession.ready); + const firstSocket = yield* awaitSocket(sockets); + + firstSocket.open(); + yield* completeInitialConfig(firstSocket, ENCODED_WEBRTC_SERVER_CONFIG); + yield* completeWebRtcSignaling(firstSocket); + yield* Fiber.join(firstReady); + expect(firstSession.transport).toBe("webrtc"); + + const firstClosed = yield* Effect.flip(firstSession.closed).pipe(Effect.forkChild); + rtc.clientPort.close(); + const closeError = yield* Fiber.join(firstClosed); + expect(closeError).toMatchObject({ reason: "transport" }); + + const secondSession = yield* factory.connect(PREPARED); + const secondReady = yield* Effect.forkChild(secondSession.ready); + for (let attempt = 0; attempt < 100 && sockets.length < 2; attempt += 1) { + yield* Effect.yieldNow; + } + const secondSocket = sockets[1]; + if (secondSocket === undefined) { + return yield* Effect.die(new Error("Expected a replacement WebSocket.")); + } + secondSocket.open(); + yield* completeInitialConfig(secondSocket, ENCODED_WEBRTC_SERVER_CONFIG); + yield* Fiber.join(secondReady); + + expect(secondSession.transport).toBe("websocket"); + expect(secondSocket.sent).toHaveLength(1); + expect(yield* Queue.size(rtc.created)).toBe(1); + }), + ); + it.effect("owns one scoped websocket attempt and exposes readiness and closure", () => Effect.gen(function* () { const { factory, sockets } = yield* makeFactory(); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 9625effa4..a919938ba 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -1,15 +1,26 @@ -import { type ServerConfig, WS_METHODS } from "@t3tools/contracts"; +import { + type ServerConfig, + type WebRtcRpcFastPathCapability, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Deferred from "effect/Deferred"; 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 Scope from "effect/Scope"; import * as RpcClient from "effect/unstable/rpc/RpcClient"; import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as Socket from "effect/unstable/socket/Socket"; import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "./protocol.ts"; +import { WebRtcPeerFactory, type WebRtcPeerFactoryService } from "../platform/capabilities.ts"; +import { + negotiateWebRtcFastPath, + type NegotiatedWebRtcFastPath, +} from "./webrtc/FastPathNegotiator.ts"; +import { WebRtcFastPathCooldown } from "./webrtc/cooldown.ts"; import type { ConnectionAttemptError, ConnectionTransientError, @@ -28,6 +39,7 @@ export interface RpcSession { readonly ready: Effect.Effect; readonly probe: Effect.Effect; readonly closed: Effect.Effect; + readonly transport?: "websocket" | "webrtc"; } export class RpcSessionFactory extends Context.Service< @@ -67,8 +79,11 @@ function mapSessionRpcError(error: InitialConfigError | ProbeError): ConnectionA export const make = Effect.gen(function* () { const webSocketConstructor = yield* Socket.WebSocketConstructor; + const webRtcPeerFactory = yield* WebRtcPeerFactory; + const webRtcCooldown = new WebRtcFastPathCooldown(); const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { + const scope = yield* Scope.Scope; yield* Effect.annotateCurrentSpan({ "connection.environment.id": connection.environmentId, }); @@ -113,34 +128,152 @@ export const make = Effect.gen(function* () { const protocolContext = yield* Layer.build(protocolLayer).pipe( Effect.withSpan("environment.websocket.connect"), ); - const client = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); - const initialConfig = yield* Effect.cached( - client[WS_METHODS.serverGetConfig]({}).pipe( + const controlClient = yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + const controlInitialConfig = yield* Effect.cached( + controlClient[WS_METHODS.serverGetConfig]({}).pipe( Effect.mapError(mapSessionRpcError), Effect.withSpan("environment.initialSync"), ), ); - const probe = initialConfig.pipe( - Effect.flatMap((config) => - (config.environment.capabilities.connectionProbe === true - ? client[WS_METHODS.serverProbe]({}) - : client[WS_METHODS.serverGetConfig]({}) - ).pipe(Effect.mapError(mapSessionRpcError)), + + interface SelectedTransport { + readonly kind: "websocket" | "webrtc"; + readonly client: WsRpcProtocolClient; + readonly initialConfig: Effect.Effect; + readonly closed: Effect.Effect; + readonly close: Effect.Effect; + } + + const selectionComplete = yield* Deferred.make(); + let selected: SelectedTransport = { + kind: "websocket", + client: controlClient, + initialConfig: controlInitialConfig, + closed: Deferred.await(disconnected), + close: Effect.void, + }; + + const selectWebRtc = Effect.fn("RpcSession.selectWebRtc")(function* ( + config: ServerConfig, + peerFactory: WebRtcPeerFactoryService, + capability: WebRtcRpcFastPathCapability, + ) { + const fastPath = yield* negotiateWebRtcFastPath({ + environmentId: connection.environmentId, + capability, + controlClient, + peerFactory, + }).pipe( + Scope.provide(scope), + Effect.matchEffect({ + onFailure: (error) => + Effect.logDebug("WebRTC RPC fast path fell back to WebSocket.").pipe( + Effect.annotateLogs({ + "rpc.transport": "websocket", + "webrtc.attempt.result": "fallback", + "webrtc.fallback.reason": error.reason, + }), + Effect.as(null), + ), + onSuccess: (fastPath) => Effect.succeed(fastPath), + }), + ); + if (fastPath === null) { + return; + } + const rtcClosed = fastPath.closed.pipe( + Effect.tapError(() => + Effect.gen(function* () { + const nowMs = yield* Clock.currentTimeMillis; + webRtcCooldown.start(connection.environmentId, nowMs); + yield* fastPath.close; + }), + ), + Effect.mapError( + () => + new ConnectionTransientErrorClass({ + reason: "transport", + detail: `${connection.label} WebRTC transport disconnected.`, + }), + ), + ); + selected = { + kind: "webrtc", + client: fastPath.client, + initialConfig: Effect.succeed(fastPath.initialConfig), + closed: rtcClosed, + close: fastPath.close, + }; + }); + + const selectTransport = Effect.gen(function* () { + const config = yield* controlInitialConfig; + const capability = config.environment.capabilities.webRtcRpcFastPath; + let fallbackReason: "capability-absent" | "platform-absent" | "cooldown" | null = null; + if (capability === undefined) { + fallbackReason = "capability-absent"; + } else if (webRtcPeerFactory === null) { + fallbackReason = "platform-absent"; + } else { + const nowMs = yield* Clock.currentTimeMillis; + if (webRtcCooldown.isActive(connection.environmentId, nowMs)) { + fallbackReason = "cooldown"; + } else { + yield* selectWebRtc(config, webRtcPeerFactory, capability); + } + } + if (fallbackReason !== null) { + yield* Effect.logDebug("Using WebSocket RPC transport.").pipe( + Effect.annotateLogs({ + "rpc.transport": "websocket", + "webrtc.fallback.reason": fallbackReason, + }), + ); + } + yield* Deferred.succeed(selectionComplete, undefined); + }); + const ready = yield* Effect.cached( + Deferred.await(connected).pipe( + Effect.andThen(selectTransport), + Effect.raceFirst(Deferred.await(disconnected)), ), - Effect.asVoid, - Effect.withSpan("clientRuntime.connection.rpcSession.probe"), ); - - return { - client, - initialConfig, - ready: Deferred.await(connected).pipe( - Effect.andThen(initialConfig), + const probe = Effect.suspend(() => + selected.initialConfig.pipe( + Effect.flatMap((config) => + (config.environment.capabilities.connectionProbe === true + ? selected.client[WS_METHODS.serverProbe]({}) + : selected.client[WS_METHODS.serverGetConfig]({}) + ).pipe(Effect.mapError(mapSessionRpcError)), + ), Effect.asVoid, - Effect.raceFirst(Deferred.await(disconnected)), + Effect.withSpan("clientRuntime.connection.rpcSession.probe"), + ), + ); + const closed = Effect.raceFirst( + Deferred.await(disconnected).pipe( + Effect.tapError(() => Effect.suspend(() => selected.close)), ), + Deferred.await(selectionComplete).pipe( + Effect.andThen( + Effect.suspend(() => (selected.kind === "webrtc" ? selected.closed : Effect.never)), + ), + ), + ); + + return { + get client() { + return selected.client; + }, + get initialConfig() { + return selected.initialConfig; + }, + ready, probe, - closed: Deferred.await(disconnected), + closed, + get transport() { + return selected.kind; + }, } satisfies RpcSession; }); diff --git a/packages/client-runtime/src/rpc/webrtc/FastPathNegotiator.ts b/packages/client-runtime/src/rpc/webrtc/FastPathNegotiator.ts new file mode 100644 index 000000000..3a48ae87f --- /dev/null +++ b/packages/client-runtime/src/rpc/webrtc/FastPathNegotiator.ts @@ -0,0 +1,238 @@ +import { + type EnvironmentId, + type ServerConfig, + WebRtcBindingFrame, + type WebRtcRpcFastPathCapability, + WS_METHODS, +} from "@t3tools/contracts"; +import { + validateSessionDescription, + validateStunUrls, +} from "@t3tools/shared/webrtcCandidatePolicy"; +import { + makeWebRtcDataChannelConnection, + type WebRtcDataChannelConnection, +} from "@t3tools/shared/webrtcDataChannel"; +import * as Clock from "effect/Clock"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as RpcClient from "effect/unstable/rpc/RpcClient"; +import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; +import * as Socket from "effect/unstable/socket/Socket"; + +import type { WebRtcPeerFactoryService } from "../../platform/capabilities.ts"; +import { makeWsRpcProtocolClient, type WsRpcProtocolClient } from "../protocol.ts"; + +const FAST_PATH_NEGOTIATION_TIMEOUT = "10 seconds"; +let nextAttemptId = 0; + +export const WebRtcFastPathFallbackReason = Schema.Literals([ + "invalid-capability", + "offer-failed", + "signaling-rejected", + "invalid-answer", + "datachannel-failed", + "binding-failed", + "rpc-probe-failed", + "identity-mismatch", + "timeout", +]); +export type WebRtcFastPathFallbackReason = typeof WebRtcFastPathFallbackReason.Type; + +export class WebRtcFastPathNegotiationError extends Schema.TaggedErrorClass()( + "WebRtcFastPathNegotiationError", + { reason: WebRtcFastPathFallbackReason }, +) { + override get message(): string { + return `WebRTC fast path negotiation failed: ${this.reason}.`; + } +} + +export class WebRtcFastPathTransportClosedError extends Schema.TaggedErrorClass()( + "WebRtcFastPathTransportClosedError", + {}, +) { + override get message(): string { + return "WebRTC fast path transport closed."; + } +} + +export interface NegotiatedWebRtcFastPath { + readonly client: WsRpcProtocolClient; + readonly initialConfig: ServerConfig; + readonly closed: Effect.Effect; + readonly close: Effect.Effect; +} + +const encodeBindingFrame = Schema.encodeSync(Schema.fromJsonString(WebRtcBindingFrame)); + +function makeRtcRpcClient( + connection: WebRtcDataChannelConnection, +): Effect.Effect { + const protocolLayer = Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ + retryTransientErrors: false, + }), + ).pipe( + Layer.provide( + Layer.mergeAll(Layer.succeed(Socket.Socket, connection.socket), RpcSerialization.layerJson), + ), + ); + return Effect.gen(function* () { + const protocolContext = yield* Layer.build(protocolLayer); + return yield* makeWsRpcProtocolClient.pipe(Effect.provide(protocolContext)); + }); +} + +export const negotiateWebRtcFastPath = Effect.fn("RpcSession.negotiateWebRtcFastPath")( + function* (options: { + readonly environmentId: EnvironmentId; + readonly capability: WebRtcRpcFastPathCapability; + readonly controlClient: WsRpcProtocolClient; + readonly peerFactory: WebRtcPeerFactoryService; + }) { + const parentScope = yield* Scope.Scope; + const attemptScope = yield* Scope.make(); + yield* Scope.addFinalizer(parentScope, Scope.close(attemptScope, Exit.void)); + nextAttemptId = nextAttemptId === Number.MAX_SAFE_INTEGER ? 1 : nextAttemptId + 1; + const attemptId = `webrtc-${nextAttemptId}`; + let signalingStarted = false; + + const abort = options.controlClient[WS_METHODS.transportWebRtcAbort]({ + attemptId, + }).pipe(Effect.ignore); + const cleanup = Scope.close(attemptScope, Exit.void).pipe( + Effect.andThen(Effect.suspend(() => (signalingStarted ? abort : Effect.void))), + ); + + const negotiate = Effect.gen(function* () { + const startedAtMs = yield* Clock.currentTimeMillis; + const stunUrls = yield* Effect.try({ + try: () => validateStunUrls(options.capability.stunUrls), + catch: () => new WebRtcFastPathNegotiationError({ reason: "invalid-capability" }), + }); + const peer = yield* options.peerFactory + .create(stunUrls) + .pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "offer-failed" })), + ); + const offerSdp = yield* peer.createOffer.pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "offer-failed" })), + ); + yield* Effect.try({ + try: () => validateSessionDescription(offerSdp), + catch: () => new WebRtcFastPathNegotiationError({ reason: "offer-failed" }), + }); + signalingStarted = true; + const answer = yield* options.controlClient[WS_METHODS.transportWebRtcNegotiate]({ + version: 1, + attemptId, + offerSdp, + }).pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "signaling-rejected" })), + ); + if (answer.attemptId !== attemptId) { + return yield* new WebRtcFastPathNegotiationError({ reason: "invalid-answer" }); + } + yield* Effect.try({ + try: () => validateSessionDescription(answer.answerSdp), + catch: () => new WebRtcFastPathNegotiationError({ reason: "invalid-answer" }), + }); + yield* peer + .acceptAnswer(answer.answerSdp) + .pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "invalid-answer" })), + ); + const dataChannelStartedAtMs = yield* Clock.currentTimeMillis; + const dataChannel = yield* makeWebRtcDataChannelConnection(peer.dataChannel).pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "datachannel-failed" })), + ); + yield* dataChannel.awaitOpen.pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "datachannel-failed" })), + ); + const dataChannelOpenedAtMs = yield* Clock.currentTimeMillis; + yield* dataChannel + .sendBinding( + new TextEncoder().encode( + encodeBindingFrame({ + version: 1, + attemptId, + bindingToken: answer.bindingToken, + }), + ), + ) + .pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "binding-failed" })), + ); + yield* dataChannel.awaitBindingAck.pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "binding-failed" })), + ); + const client = yield* makeRtcRpcClient(dataChannel); + yield* client[WS_METHODS.serverProbe]({}).pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "rpc-probe-failed" })), + ); + const initialConfig = yield* client[WS_METHODS.serverGetConfig]({}).pipe( + Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "rpc-probe-failed" })), + ); + if (initialConfig.environment.environmentId !== options.environmentId) { + return yield* new WebRtcFastPathNegotiationError({ reason: "identity-mismatch" }); + } + const selectedAtMs = yield* Clock.currentTimeMillis; + yield* Effect.logDebug("Selected WebRTC RPC fast path.").pipe( + Effect.annotateLogs({ + "rpc.transport": "webrtc", + "webrtc.attempt.result": "selected", + "webrtc.negotiation_ms": selectedAtMs - startedAtMs, + "webrtc.datachannel_open_ms": dataChannelOpenedAtMs - dataChannelStartedAtMs, + }), + ); + yield* Effect.addFinalizer(() => + Effect.all({ + stats: dataChannel.stats, + selectedIcePairType: peer.selectedIcePairType.pipe(Effect.orElseSucceed(() => null)), + }).pipe( + Effect.flatMap(({ stats, selectedIcePairType }) => + Effect.logDebug("WebRTC RPC fast path closed.").pipe( + Effect.annotateLogs({ + "rpc.transport": "webrtc", + "webrtc.bytes_sent": stats.bytesSent, + "webrtc.bytes_received": stats.bytesReceived, + ...(selectedIcePairType === null + ? {} + : { "webrtc.selected_ice_pair_type": selectedIcePairType }), + }), + ), + ), + ), + ); + const closed = Effect.raceFirst(peer.closed, dataChannel.closed).pipe( + Effect.mapError(() => new WebRtcFastPathTransportClosedError({})), + ); + const close = Effect.all([peer.close, dataChannel.close], { + discard: true, + concurrency: "unbounded", + }); + return { + client, + initialConfig, + closed, + close, + } satisfies NegotiatedWebRtcFastPath; + }).pipe( + Scope.provide(attemptScope), + Effect.timeoutOrElse({ + duration: FAST_PATH_NEGOTIATION_TIMEOUT, + orElse: () => Effect.fail(new WebRtcFastPathNegotiationError({ reason: "timeout" })), + }), + ); + + return yield* negotiate.pipe( + Effect.tapError(() => cleanup), + Effect.onInterrupt(() => cleanup), + ); + }, +); diff --git a/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.test.ts b/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.test.ts new file mode 100644 index 000000000..02f011b7d --- /dev/null +++ b/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "vite-plus/test"; + +import { selectedIcePairTypeFromStats } from "./WebRtcPeer.ts"; + +it("reports only the selected ICE candidate pair types", () => { + const stats = new Map([ + ["transport", { type: "transport", selectedCandidatePairId: "pair" }], + [ + "pair", + { + type: "candidate-pair", + state: "succeeded", + localCandidateId: "local", + remoteCandidateId: "remote", + }, + ], + ["local", { type: "local-candidate", candidateType: "host", address: "192.0.2.1" }], + ["remote", { type: "remote-candidate", candidateType: "srflx", address: "198.51.100.1" }], + ]); + + expect(selectedIcePairTypeFromStats(stats)).toBe("host/srflx"); +}); + +it("does not expose unknown candidate data", () => { + const stats = new Map([ + ["transport", { type: "transport", selectedCandidatePairId: "pair" }], + ["pair", { localCandidateId: "local", remoteCandidateId: "remote" }], + ["local", { candidateType: "future-type", address: "192.0.2.1" }], + ["remote", { candidateType: "host", address: "198.51.100.1" }], + ]); + + expect(selectedIcePairTypeFromStats(stats)).toBeNull(); +}); diff --git a/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.ts b/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.ts new file mode 100644 index 000000000..cec4d1a12 --- /dev/null +++ b/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.ts @@ -0,0 +1,177 @@ +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; + +import { + isTerminalWebRtcPeerConnectionState, + type WebRtcPeerConnectionState, +} from "@t3tools/shared/webrtcPeerState"; +import { + WebRtcPeerError, + type WebRtcPeer, + type WebRtcPeerFactoryService, +} from "../../platform/capabilities.ts"; +import type { WebRtcDataChannelPort } from "@t3tools/shared/webrtcDataChannel"; + +const ICE_CANDIDATE_TYPES = new Set(["host", "prflx", "relay", "srflx"]); + +export interface WebRtcSessionDescription { + readonly type: "offer" | "answer"; + readonly sdp: string; +} + +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 selectedIcePairType: () => Promise; + readonly close: () => void; +} + +export interface WebRtcStatsReportLike { + readonly get: (id: string) => unknown; + readonly forEach: (callback: (report: unknown) => void) => void; +} + +export function selectedIcePairTypeFromStats(stats: WebRtcStatsReportLike): string | null { + let selectedPairId: string | null = null; + let nominatedPair: Readonly> | null = null; + stats.forEach((value) => { + if (typeof value !== "object" || value === null) { + return; + } + const report = value as Readonly>; + if (report.type === "transport" && typeof report.selectedCandidatePairId === "string") { + selectedPairId = report.selectedCandidatePairId; + } + if ( + report.type === "candidate-pair" && + report.state === "succeeded" && + report.nominated === true + ) { + nominatedPair = report; + } + }); + + const selectedPair = selectedPairId === null ? nominatedPair : stats.get(selectedPairId); + if (typeof selectedPair !== "object" || selectedPair === null) { + return null; + } + const pair = selectedPair as Readonly>; + if (typeof pair.localCandidateId !== "string" || typeof pair.remoteCandidateId !== "string") { + return null; + } + const localCandidate = stats.get(pair.localCandidateId); + const remoteCandidate = stats.get(pair.remoteCandidateId); + if ( + typeof localCandidate !== "object" || + localCandidate === null || + typeof remoteCandidate !== "object" || + remoteCandidate === null + ) { + return null; + } + const localType = (localCandidate as Readonly>).candidateType; + const remoteType = (remoteCandidate as Readonly>).candidateType; + if ( + typeof localType !== "string" || + !ICE_CANDIDATE_TYPES.has(localType) || + typeof remoteType !== "string" || + !ICE_CANDIDATE_TYPES.has(remoteType) + ) { + return null; + } + return `${localType}/${remoteType}`; +} + +export function makeWebRtcPeerFactory( + createPeerConnection: (stunUrls: ReadonlyArray) => PlatformWebRtcPeerConnection, +): WebRtcPeerFactoryService { + return { + create: Effect.fn("WebRtcPeerFactory.create")(function* (stunUrls: ReadonlyArray) { + const peer = yield* Effect.try({ + try: () => createPeerConnection(stunUrls), + 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 (isTerminalWebRtcPeerConnectionState(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("t3-rpc-v1"), + catch: (cause) => new WebRtcPeerError({ stage: "create", cause }), + }); + + const createOffer = Effect.gen(function* () { + const offer = yield* Effect.tryPromise({ + try: () => peer.createOffer(), + catch: (cause) => new WebRtcPeerError({ stage: "offer", cause }), + }); + 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("WebRtcPeer.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), + selectedIcePairType: Effect.tryPromise({ + try: peer.selectedIcePairType, + catch: (cause) => new WebRtcPeerError({ stage: "stats", cause }), + }), + close: Effect.sync(() => peer.close()), + } satisfies WebRtcPeer; + }), + }; +} diff --git a/packages/client-runtime/src/rpc/webrtc/cooldown.ts b/packages/client-runtime/src/rpc/webrtc/cooldown.ts new file mode 100644 index 000000000..cba5ec3fc --- /dev/null +++ b/packages/client-runtime/src/rpc/webrtc/cooldown.ts @@ -0,0 +1,28 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +export const WEBRTC_FAST_PATH_COOLDOWN_MS = 30_000; + +export class WebRtcFastPathCooldown { + readonly #cooldownMs: number; + readonly #untilByEnvironment = new Map(); + + constructor(cooldownMs = WEBRTC_FAST_PATH_COOLDOWN_MS) { + this.#cooldownMs = cooldownMs; + } + + isActive(environmentId: EnvironmentId, nowMs: number): boolean { + const untilMs = this.#untilByEnvironment.get(environmentId); + if (untilMs === undefined) { + return false; + } + if (nowMs >= untilMs) { + this.#untilByEnvironment.delete(environmentId); + return false; + } + return true; + } + + start(environmentId: EnvironmentId, nowMs: number): void { + this.#untilByEnvironment.set(environmentId, nowMs + this.#cooldownMs); + } +} diff --git a/packages/client-runtime/src/state/shell-sync.test.ts b/packages/client-runtime/src/state/shell-sync.test.ts index 40e9bd80d..8cfc3a828 100644 --- a/packages/client-runtime/src/state/shell-sync.test.ts +++ b/packages/client-runtime/src/state/shell-sync.test.ts @@ -47,17 +47,90 @@ const LIVE_SHELL_SNAPSHOT: OrchestrationShellSnapshot = { updatedAt: "2026-06-06T00:00:00.000Z", }; -function session(client: WsRpcProtocolClient): RpcSession.RpcSession { +function session( + client: WsRpcProtocolClient, + transport: "websocket" | "webrtc" = "websocket", +): RpcSession.RpcSession { return { client, initialConfig: Effect.succeed({ shellResumeCompletionMarker: true } as never), ready: Effect.void, probe: Effect.void, closed: Effect.never, + transport, }; } describe("environment shell synchronization", () => { + it.effect("uses a complete WebRTC subscription snapshot without an HTTP load", () => + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const subscribeInputs = yield* Queue.unbounded<{ + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }>(); + const loaderCalls = yield* Ref.make(0); + const client = { + [ORCHESTRATION_WS_METHODS.subscribeShell]: (input: { + readonly afterSequence?: number; + readonly requestCompletionMarker?: boolean; + }) => + Stream.unwrap( + Queue.offer(subscribeInputs, input).pipe(Effect.as(Stream.fromQueue(events))), + ), + } as unknown as WsRpcProtocolClient; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: yield* SubscriptionRef.make(Option.some(session(client, "webrtc"))), + prepared: yield* SubscriptionRef.make(Option.some(PREPARED)), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const cache = Persistence.EnvironmentCacheStore.of({ + loadShell: () => Effect.succeed(Option.none()), + saveShell: () => Effect.void, + loadThread: () => Effect.succeed(Option.none()), + saveThread: () => Effect.void, + removeThread: () => Effect.void, + loadServerConfig: () => Effect.succeed(Option.none()), + saveServerConfig: () => Effect.void, + loadVcsRefs: () => Effect.succeed(Option.none()), + saveVcsRefs: () => Effect.void, + removeVcsRefs: () => Effect.void, + clearVcsRefs: () => Effect.void, + clear: () => Effect.void, + }); + const shellState = yield* makeEnvironmentShellState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.provideService(Persistence.EnvironmentCacheStore, cache), + Effect.provideService( + ShellSnapshotLoader, + ShellSnapshotLoader.of({ + load: () => + Ref.update(loaderCalls, (count) => count + 1).pipe(Effect.as(Option.none())), + }), + ), + ); + + const input = yield* Queue.take(subscribeInputs); + expect(input.afterSequence).toBeUndefined(); + expect(input.requestCompletionMarker).toBe(true); + expect(yield* Ref.get(loaderCalls)).toBe(0); + + yield* Queue.offer(events, { kind: "snapshot", snapshot: LIVE_SHELL_SNAPSHOT }); + yield* Queue.offer(events, { kind: "synchronized" }); + yield* SubscriptionRef.changes(shellState).pipe( + Stream.filter((value) => value.status === "live"), + Stream.runHead, + ); + expect(Option.getOrThrow((yield* SubscriptionRef.get(shellState)).snapshot)).toEqual( + LIVE_SHELL_SNAPSHOT, + ); + }), + ); + it.effect("publishes live state before persistence and preserves it when ready", () => Effect.gen(function* () { const events = yield* Queue.unbounded(); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index c150bbb75..cf71b640b 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -203,7 +203,10 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") const hasAuthoritativeSnapshot = (yield* Ref.get(lastAuthoritativeSession)) === session; let canResume = hasAuthoritativeSnapshot; let current = yield* SubscriptionRef.get(state); - if (!hasAuthoritativeSnapshot || Option.isNone(current.snapshot)) { + if ( + session.transport !== "webrtc" && + (!hasAuthoritativeSnapshot || Option.isNone(current.snapshot)) + ) { const prepared = yield* SubscriptionRef.get(supervisor.prepared).pipe( Effect.flatMap( Option.match({ diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 5fe47b341..8d491f1a7 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -110,6 +110,7 @@ function testSession( options?: { readonly completionMarker?: boolean; readonly messagePagination?: boolean; + readonly transport?: "websocket" | "webrtc"; }, ): RpcSession.RpcSession { return { @@ -121,6 +122,7 @@ function testSession( ready: Effect.void, probe: Effect.void, closed: Effect.never, + ...(options?.transport === undefined ? {} : { transport: options.transport }), }; } @@ -141,6 +143,7 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o readonly httpSnapshotEffect?: Effect.Effect>; readonly completionMarker?: boolean; readonly messagePagination?: boolean; + readonly transport?: "websocket" | "webrtc"; }) { const inputs = yield* Queue.unbounded(); const observed = yield* Queue.unbounded(); @@ -180,10 +183,13 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o Option.some( testSession( client, - options?.completionMarker === true || options?.messagePagination === true + options?.completionMarker === true || + options?.messagePagination === true || + options?.transport !== undefined ? { ...(options.completionMarker === true ? { completionMarker: true } : {}), ...(options.messagePagination === true ? { messagePagination: true } : {}), + ...(options.transport === undefined ? {} : { transport: options.transport }), } : undefined, ), @@ -360,6 +366,24 @@ const deleted = (): OrchestrationThreadStreamItem => ({ }); describe("EnvironmentThreads", () => { + it.effect("uses a complete WebRTC subscription snapshot without an HTTP load", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + transport: "webrtc", + httpSnapshot: Option.some({ snapshotSequence: 99, thread: BASE_THREAD }), + }); + yield* Queue.offer(harness.inputs, snapshot(BASE_THREAD)); + const state = yield* awaitThreadState( + harness.observed, + (value) => value.status === "live" && Option.isSome(value.data), + ); + + expect(Option.getOrThrow(state.data)).toEqual(BASE_THREAD); + expect(yield* Ref.get(harness.loaderCalls)).toBe(0); + expect(yield* Ref.get(harness.lastSubscribeAfterSequence)).toBeUndefined(); + }), + ); + it.effect("publishes cached data immediately from a warm cache", () => Effect.gen(function* () { const harness = yield* makeHarness({ cached: BASE_THREAD }); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index a98320e57..d8bc438da 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -28,6 +28,7 @@ import * as ConnectionWakeups from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import { ThreadHistoryCacheStore } from "../platform/threadHistoryCache.ts"; import { subscribeDynamicRequest } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { THREAD_TURN_PAGE_SIZE, ThreadSnapshotLoader } from "./threadSnapshotHttp.ts"; import { boundLiveThread, @@ -229,6 +230,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make : Option.flatMap(cached, (snapshot) => pageStateFromSnapshot(snapshot.page)), }); const liveThread = yield* Ref.make(cachedThread); + const lastAuthoritativeSession = yield* Ref.make(null); + const activeSubscriptionSession = yield* Ref.make(null); const historyOutlineRefreshes = yield* SubscriptionRef.make(0); const threadSnapshotRefreshes = yield* SubscriptionRef.make(0); const messagePaginationSupported = yield* SubscriptionRef.make(false); @@ -1013,6 +1016,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } yield* SubscriptionRef.set(lastSequence, item.snapshot.snapshotSequence); yield* setThread(item.snapshot.thread, pageStateFromSnapshot(item.snapshot.page)); + const session = yield* Ref.get(activeSubscriptionSession); + if (session !== null) { + yield* Ref.set(lastAuthoritativeSession, session); + } return; } @@ -1291,6 +1298,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make yield* Effect.forkScoped( subscribeDynamicRequest( Effect.fn("EnvironmentThreadState.makeSubscriptionRequest")(function* (session) { + yield* Ref.set(activeSubscriptionSession, session); const messagePaginationEnabled = options?.messagePagination?.enabled() ?? false; const subscriptionCapabilities = yield* session.initialConfig.pipe( Effect.map((config) => ({ @@ -1365,7 +1373,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make yield* SubscriptionRef.set(lastSequence, 0); current = yield* SubscriptionRef.get(state); } - if (Option.isNone(current.data) && current.status !== "deleted") { + if ( + session.transport !== "webrtc" && + Option.isNone(current.data) && + current.status !== "deleted" + ) { const prepared = yield* preparedConnection; const httpSnapshot = supportsMessagePagination ? yield* snapshotLoader.loadMessageHistory(prepared, threadId, THREAD_TURN_PAGE_SIZE) @@ -1385,8 +1397,10 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make } const sequence = yield* SubscriptionRef.get(lastSequence); + const hasAuthoritativeSnapshot = (yield* Ref.get(lastAuthoritativeSession)) === session; const canResume = Option.isSome(current.data) && + (session.transport !== "webrtc" || hasAuthoritativeSnapshot) && (!supportsMessagePagination || current.data.value.messageHistory !== undefined); if (!supportsCompletionMarker && canResume) { yield* SubscriptionRef.update(state, (value) => ({ diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 27ea67b1d..0beca46ac 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { EnvironmentId, ProjectId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { WebRtcRpcFastPathCapability } from "./webrtc.ts"; export const ExecutionEnvironmentPlatformOs = Schema.Literals([ "darwin", @@ -74,6 +75,9 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server can stream self-update progress before acknowledging the restart. Clients fall back to server.updateServer when absent. */ serverSelfUpdateProgress: Schema.optionalKey(Schema.Boolean), + /** Optional direct WsRpcGroup transport negotiated over the authenticated + control WebSocket. Missing means clients stay on WebSocket. */ + webRtcRpcFastPath: Schema.optionalKey(WebRtcRpcFastPathCapability), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 6181391ec..359e76917 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -29,4 +29,5 @@ export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; +export * from "./webrtc.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0a174dc73..648e42b0e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -104,6 +104,13 @@ import { TerminalSessionSnapshot, TerminalWriteInput, } from "./terminal.ts"; +import { + WebRtcAbortInput, + WebRtcAbortResult, + WebRtcNegotiateInput, + WebRtcNegotiateResult, + WebRtcSignalingError, +} from "./webrtc.ts"; import { DiscoveredLocalServerList, PreviewCloseInput, @@ -248,6 +255,10 @@ export const WS_METHODS = { serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", + // Optional transports + transportWebRtcNegotiate: "transport.webrtc.negotiate", + transportWebRtcAbort: "transport.webrtc.abort", + // Cloud environment methods cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", @@ -294,6 +305,18 @@ export const WsServerGetConfigRpc = Rpc.make(WS_METHODS.serverGetConfig, { error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), }); +export const WsTransportWebRtcNegotiateRpc = Rpc.make(WS_METHODS.transportWebRtcNegotiate, { + payload: WebRtcNegotiateInput, + success: WebRtcNegotiateResult, + error: Schema.Union([WebRtcSignalingError, EnvironmentAuthorizationError]), +}); + +export const WsTransportWebRtcAbortRpc = Rpc.make(WS_METHODS.transportWebRtcAbort, { + payload: WebRtcAbortInput, + success: WebRtcAbortResult, + error: Schema.Union([WebRtcSignalingError, EnvironmentAuthorizationError]), +}); + export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProviders, { payload: Schema.Struct({ /** @@ -824,6 +847,8 @@ export const WsSubscribeResourceTelemetryRpc = Rpc.make(WS_METHODS.subscribeReso export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, + WsTransportWebRtcNegotiateRpc, + WsTransportWebRtcAbortRpc, WsServerRefreshProvidersRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, diff --git a/packages/contracts/src/webrtc.ts b/packages/contracts/src/webrtc.ts new file mode 100644 index 000000000..cea17802c --- /dev/null +++ b/packages/contracts/src/webrtc.ts @@ -0,0 +1,82 @@ +import * as Schema from "effect/Schema"; + +import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const WebRtcRpcFastPathCapability = Schema.Struct({ + version: Schema.Literal(1), + signaling: Schema.Literal("same-websocket-rpc"), + turn: Schema.Literal(false), + stunUrls: Schema.Array(TrimmedNonEmptyString), +}); +export type WebRtcRpcFastPathCapability = typeof WebRtcRpcFastPathCapability.Type; + +export const WebRtcNegotiateInput = Schema.Struct({ + version: Schema.Literal(1), + attemptId: TrimmedNonEmptyString, + offerSdp: Schema.String, +}); +export type WebRtcNegotiateInput = typeof WebRtcNegotiateInput.Type; + +export const WebRtcNegotiateResult = Schema.Struct({ + version: Schema.Literal(1), + attemptId: TrimmedNonEmptyString, + answerSdp: Schema.String, + bindingToken: TrimmedNonEmptyString, + expiresAt: IsoDateTime, +}); +export type WebRtcNegotiateResult = typeof WebRtcNegotiateResult.Type; + +export const WebRtcAbortInput = Schema.Struct({ + attemptId: TrimmedNonEmptyString, +}); +export type WebRtcAbortInput = typeof WebRtcAbortInput.Type; + +export const WebRtcAbortResult = Schema.Struct({}); +export type WebRtcAbortResult = typeof WebRtcAbortResult.Type; + +export const WebRtcBindingFrame = Schema.Struct({ + version: Schema.Literal(1), + attemptId: TrimmedNonEmptyString, + bindingToken: TrimmedNonEmptyString, +}); +export type WebRtcBindingFrame = typeof WebRtcBindingFrame.Type; + +export class WebRtcFastPathDisabledError extends Schema.TaggedErrorClass()( + "WebRtcFastPathDisabledError", + { message: TrimmedNonEmptyString }, +) {} + +export class WebRtcFastPathUnsupportedError extends Schema.TaggedErrorClass()( + "WebRtcFastPathUnsupportedError", + { message: TrimmedNonEmptyString }, +) {} + +export class WebRtcFastPathBusyError extends Schema.TaggedErrorClass()( + "WebRtcFastPathBusyError", + { message: TrimmedNonEmptyString }, +) {} + +export class WebRtcFastPathInvalidAttemptError extends Schema.TaggedErrorClass()( + "WebRtcFastPathInvalidAttemptError", + { message: TrimmedNonEmptyString }, +) {} + +export class WebRtcFastPathInvalidSdpError extends Schema.TaggedErrorClass()( + "WebRtcFastPathInvalidSdpError", + { message: TrimmedNonEmptyString }, +) {} + +export class WebRtcFastPathNegotiationError extends Schema.TaggedErrorClass()( + "WebRtcFastPathNegotiationError", + { message: TrimmedNonEmptyString }, +) {} + +export const WebRtcSignalingError = Schema.Union([ + WebRtcFastPathDisabledError, + WebRtcFastPathUnsupportedError, + WebRtcFastPathBusyError, + WebRtcFastPathInvalidAttemptError, + WebRtcFastPathInvalidSdpError, + WebRtcFastPathNegotiationError, +]); +export type WebRtcSignalingError = typeof WebRtcSignalingError.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index f782eef5a..82113fcd4 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -230,6 +230,22 @@ "./usageFormat": { "types": "./src/usageFormat.ts", "import": "./src/usageFormat.ts" + }, + "./webrtcCandidatePolicy": { + "types": "./src/webrtcCandidatePolicy.ts", + "import": "./src/webrtcCandidatePolicy.ts" + }, + "./webrtcDataChannel": { + "types": "./src/webrtcDataChannel.ts", + "import": "./src/webrtcDataChannel.ts" + }, + "./webrtcFraming": { + "types": "./src/webrtcFraming.ts", + "import": "./src/webrtcFraming.ts" + }, + "./webrtcPeerState": { + "types": "./src/webrtcPeerState.ts", + "import": "./src/webrtcPeerState.ts" } }, "scripts": { diff --git a/packages/shared/src/webrtcCandidatePolicy.test.ts b/packages/shared/src/webrtcCandidatePolicy.test.ts new file mode 100644 index 000000000..0d82d66e7 --- /dev/null +++ b/packages/shared/src/webrtcCandidatePolicy.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + validateIceCandidate, + validateSessionDescription, + validateStunUrls, + WebRtcCandidatePolicyError, +} from "./webrtcCandidatePolicy.ts"; + +describe("WebRTC no-TURN policy", () => { + it("accepts STUN URLs", () => { + expect( + validateStunUrls(["stun:stun.example.test:3478", "stuns:stun.example.test:5349"]), + ).toEqual(["stun:stun.example.test:3478", "stuns:stun.example.test:5349"]); + }); + + it.each(["turn:turn.example.test:3478", "turns:turn.example.test:5349"])( + "rejects %s URLs", + (url) => { + expect(() => validateStunUrls([url])).toThrowError(WebRtcCandidatePolicyError); + }, + ); + + it("rejects relay candidates", () => { + expect(() => + validateIceCandidate( + "candidate:1 1 UDP 1677734910 203.0.113.1 50000 typ relay raddr 0.0.0.0 rport 0", + ), + ).toThrowError(WebRtcCandidatePolicyError); + }); + + it("rejects SDP containing relay candidates", () => { + expect(() => + validateSessionDescription( + "v=0\r\na=candidate:1 1 UDP 1677734910 203.0.113.1 50000 typ relay\r\n", + ), + ).toThrowError(WebRtcCandidatePolicyError); + }); +}); diff --git a/packages/shared/src/webrtcCandidatePolicy.ts b/packages/shared/src/webrtcCandidatePolicy.ts new file mode 100644 index 000000000..ba9715ed6 --- /dev/null +++ b/packages/shared/src/webrtcCandidatePolicy.ts @@ -0,0 +1,78 @@ +export const DEFAULT_WEBRTC_STUN_URLS = ["stun:stun.cloudflare.com:3478"] as const; + +export const MAX_WEBRTC_SDP_BYTES = 256 * 1024; +export const MAX_WEBRTC_SDP_CANDIDATES = 128; + +export type WebRtcCandidatePolicyErrorCode = + | "turn-url" + | "unsupported-url" + | "relay-candidate" + | "sdp-too-large" + | "too-many-candidates"; + +export class WebRtcCandidatePolicyError extends Error { + readonly code: WebRtcCandidatePolicyErrorCode; + + constructor(code: WebRtcCandidatePolicyErrorCode, message: string) { + super(message); + this.name = "WebRtcCandidatePolicyError"; + this.code = code; + } +} + +const STUN_URL_PATTERN = /^stuns?:[^\s]+$/i; +const TURN_URL_PATTERN = /^turns?:/i; +const RELAY_CANDIDATE_PATTERN = /(?:^|\s)typ\s+relay(?:\s|$)/i; +const SDP_CANDIDATE_PATTERN = /^a=candidate:/gm; + +export function validateStunUrls(urls: ReadonlyArray): ReadonlyArray { + return urls.map((value) => { + const url = value.trim(); + if (TURN_URL_PATTERN.test(url)) { + throw new WebRtcCandidatePolicyError("turn-url", "TURN URLs are not allowed."); + } + if (!STUN_URL_PATTERN.test(url)) { + throw new WebRtcCandidatePolicyError( + "unsupported-url", + "WebRTC ICE servers must use the stun: or stuns: scheme.", + ); + } + return url; + }); +} + +export function validateIceCandidate(candidate: string): void { + if (RELAY_CANDIDATE_PATTERN.test(candidate)) { + throw new WebRtcCandidatePolicyError( + "relay-candidate", + "Relay ICE candidates are not allowed.", + ); + } +} + +export function validateSessionDescription( + sdp: string, + options?: { + readonly maxBytes?: number; + readonly maxCandidates?: number; + }, +): void { + const maxBytes = options?.maxBytes ?? MAX_WEBRTC_SDP_BYTES; + const maxCandidates = options?.maxCandidates ?? MAX_WEBRTC_SDP_CANDIDATES; + if (new TextEncoder().encode(sdp).byteLength > maxBytes) { + throw new WebRtcCandidatePolicyError("sdp-too-large", "WebRTC SDP exceeds the size limit."); + } + const candidateCount = [...sdp.matchAll(SDP_CANDIDATE_PATTERN)].length; + if (candidateCount > maxCandidates) { + throw new WebRtcCandidatePolicyError( + "too-many-candidates", + "WebRTC SDP contains too many ICE candidates.", + ); + } + if (RELAY_CANDIDATE_PATTERN.test(sdp)) { + throw new WebRtcCandidatePolicyError( + "relay-candidate", + "WebRTC SDP contains a relay ICE candidate.", + ); + } +} diff --git a/packages/shared/src/webrtcDataChannel.test.ts b/packages/shared/src/webrtcDataChannel.test.ts new file mode 100644 index 000000000..0b398e5b3 --- /dev/null +++ b/packages/shared/src/webrtcDataChannel.test.ts @@ -0,0 +1,210 @@ +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Queue from "effect/Queue"; +import * as TestClock from "effect/testing/TestClock"; + +import { + makeWebRtcDataChannelConnection, + type WebRtcDataChannelPort, +} from "./webrtcDataChannel.ts"; +import { + encodeWebRtcMessage, + WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES, + WEBRTC_RPC_PARTIAL_TTL_MS, + WebRtcMessageReassembler, +} from "./webrtcFraming.ts"; + +class TestDataChannelPort implements WebRtcDataChannelPort { + readonly label = "t3-rpc-v1"; + readonly ordered = true; + readonly sent: Array = []; + #open = true; + #bufferedAmount = 0; + #peer: TestDataChannelPort | null = null; + #openListeners = new Set<() => void>(); + #messageListeners = new Set<(data: Uint8Array) => void>(); + #closeListeners = new Set<() => void>(); + #errorListeners = new Set<(error: Error) => void>(); + #lowListeners = new Set<() => void>(); + + connect(peer: TestDataChannelPort): void { + this.#peer = peer; + } + + setBufferedAmount(bytes: number): void { + this.#bufferedAmount = bytes; + } + + releaseBackpressure(): void { + this.#bufferedAmount = 0; + for (const listener of this.#lowListeners) { + listener(); + } + } + + isOpen(): boolean { + return this.#open; + } + + bufferedAmount(): number { + return this.#bufferedAmount; + } + + setBufferedAmountLowThreshold(_bytes: number): void {} + + send(data: Uint8Array): void { + const copy = data.slice(); + this.sent.push(copy); + if (this.#peer !== null) { + for (const listener of this.#peer.#messageListeners) { + listener(copy); + } + } + } + + close(): void { + if (!this.#open) { + return; + } + this.#open = false; + for (const listener of this.#closeListeners) { + listener(); + } + } + + onOpen(listener: () => void): () => void { + this.#openListeners.add(listener); + return () => this.#openListeners.delete(listener); + } + + onMessage(listener: (data: Uint8Array) => void): () => void { + this.#messageListeners.add(listener); + return () => this.#messageListeners.delete(listener); + } + + onClose(listener: () => void): () => void { + this.#closeListeners.add(listener); + return () => this.#closeListeners.delete(listener); + } + + onError(listener: (error: Error) => void): () => void { + this.#errorListeners.add(listener); + return () => this.#errorListeners.delete(listener); + } + + onBufferedAmountLow(listener: () => void): () => void { + this.#lowListeners.add(listener); + return () => this.#lowListeners.delete(listener); + } +} + +function makePair() { + const left = new TestDataChannelPort(); + const right = new TestDataChannelPort(); + left.connect(right); + right.connect(left); + return { left, right }; +} + +it.effect("serializes concurrent fragmented writes", () => + Effect.gen(function* () { + const ports = makePair(); + const sender = yield* makeWebRtcDataChannelConnection(ports.left); + const receiver = yield* makeWebRtcDataChannelConnection(ports.right); + yield* sender.sendBinding(new TextEncoder().encode("binding")); + expect(new TextDecoder().decode(yield* receiver.awaitBinding)).toBe("binding"); + yield* receiver.sendBindingAck; + yield* sender.awaitBindingAck; + const writer = yield* sender.socket.writer; + const received = yield* Queue.unbounded(); + yield* receiver.socket + .runString((message) => Queue.offer(received, message)) + .pipe(Effect.forkScoped); + + const first = "a".repeat(30_000); + const second = "b".repeat(30_000); + yield* Effect.all([writer(first), writer(second)], { concurrency: "unbounded" }); + expect([yield* Queue.take(received), yield* Queue.take(received)]).toEqual([first, second]); + }), +); + +it.effect("waits for DataChannel backpressure before writing", () => + Effect.gen(function* () { + const port = new TestDataChannelPort(); + port.setBufferedAmount(300 * 1024); + const connection = yield* makeWebRtcDataChannelConnection(port); + const writer = yield* connection.socket.writer; + const fiber = yield* writer("held").pipe(Effect.forkChild); + yield* Effect.yieldNow; + expect(port.sent).toHaveLength(0); + + port.releaseBackpressure(); + yield* Fiber.join(fiber); + expect(port.sent).toHaveLength(1); + }), +); + +it.effect("allocates a new message ID when a write effect is reused", () => + Effect.gen(function* () { + const port = new TestDataChannelPort(); + const connection = yield* makeWebRtcDataChannelConnection(port); + const writer = yield* connection.socket.writer; + const write = writer("heartbeat"); + + yield* write; + yield* write; + + const decoder = new WebRtcMessageReassembler(); + expect(port.sent.map((frame) => decoder.push(frame, 0)?.messageId)).toEqual([1, 2]); + }), +); + +it.effect("propagates a clean channel close to the Effect socket", () => + Effect.gen(function* () { + const port = new TestDataChannelPort(); + const connection = yield* makeWebRtcDataChannelConnection(port); + const exitFiber = yield* connection.socket + .run(() => undefined) + .pipe(Effect.exit, Effect.forkScoped); + port.close(); + const exit = yield* Fiber.join(exitFiber); + expect(Exit.isFailure(exit)).toBe(true); + }), +); + +it.effect("closes the connection when framing is malformed", () => + Effect.gen(function* () { + const ports = makePair(); + const connection = yield* makeWebRtcDataChannelConnection(ports.right); + yield* connection.socket.run(() => undefined).pipe(Effect.exit, Effect.forkScoped); + const closedFiber = yield* connection.closed.pipe(Effect.exit, Effect.forkScoped); + + ports.left.send(new Uint8Array([1, 2, 3])); + + expect(Exit.isFailure(yield* Fiber.join(closedFiber))).toBe(true); + expect(ports.right.isOpen()).toBe(false); + }), +); + +it.effect("expires an idle partial message without waiting for another frame", () => + Effect.gen(function* () { + const ports = makePair(); + const receiver = yield* makeWebRtcDataChannelConnection(ports.right); + const exitFiber = yield* receiver.socket + .run(() => undefined) + .pipe(Effect.exit, Effect.forkScoped); + const frames = encodeWebRtcMessage({ + kind: "rpc", + messageId: 1, + payload: new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES + 1), + }); + + ports.left.send(frames[0]!); + yield* Effect.yieldNow; + yield* TestClock.adjust(WEBRTC_RPC_PARTIAL_TTL_MS); + + expect(Exit.isFailure(yield* Fiber.join(exitFiber))).toBe(true); + }).pipe(Effect.provide(TestClock.layer())), +); diff --git a/packages/shared/src/webrtcDataChannel.ts b/packages/shared/src/webrtcDataChannel.ts new file mode 100644 index 000000000..73aad3fe4 --- /dev/null +++ b/packages/shared/src/webrtcDataChannel.ts @@ -0,0 +1,243 @@ +import * as Clock from "effect/Clock"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Semaphore from "effect/Semaphore"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { + encodeWebRtcMessage, + type DecodedWebRtcMessage, + WebRtcFramingError, + WebRtcMessageReassembler, +} from "./webrtcFraming.ts"; + +const BUFFERED_AMOUNT_HIGH_BYTES = 256 * 1024; +const BUFFERED_AMOUNT_LOW_BYTES = 64 * 1024; + +export interface WebRtcDataChannelPort { + readonly label: string; + readonly ordered: boolean; + readonly isOpen: () => boolean; + readonly bufferedAmount: () => number; + readonly setBufferedAmountLowThreshold: (bytes: number) => void; + 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: (error: Error) => void) => () => void; + readonly onBufferedAmountLow: (listener: () => void) => () => void; +} + +export interface WebRtcDataChannelStats { + readonly bytesSent: number; + readonly bytesReceived: number; +} + +export interface WebRtcDataChannelConnection { + readonly awaitOpen: Effect.Effect; + readonly sendBinding: (payload: Uint8Array) => Effect.Effect; + readonly awaitBinding: Effect.Effect; + readonly sendBindingAck: Effect.Effect; + readonly awaitBindingAck: Effect.Effect; + readonly socket: Socket.Socket; + readonly closed: Effect.Effect; + readonly close: Effect.Effect; + readonly stats: Effect.Effect; +} + +function socketError(reason: Socket.SocketErrorReason): Socket.SocketError { + return new Socket.SocketError({ reason }); +} + +export const makeWebRtcDataChannelConnection = Effect.fn("WebRtcDataChannelConnection.make")( + function* (port: WebRtcDataChannelPort) { + const incoming = yield* Queue.unbounded(); + const bufferedAmountLow = yield* Queue.sliding(1); + const opened = yield* Deferred.make(); + const closed = yield* Deferred.make(); + const writeLock = yield* Semaphore.make(1); + const decoder = new WebRtcMessageReassembler(); + let nextMessageId = 1; + let bytesSent = 0; + let bytesReceived = 0; + + const closeWith = (error: Socket.SocketError) => { + Deferred.doneUnsafe(closed, Effect.fail(error)); + }; + const failConnection = (error: Socket.SocketError) => + Effect.sync(() => { + closeWith(error); + port.close(); + }); + if (port.isOpen()) { + Deferred.doneUnsafe(opened, Effect.void); + } + port.setBufferedAmountLowThreshold(BUFFERED_AMOUNT_LOW_BYTES); + const cleanups = [ + port.onOpen(() => { + Deferred.doneUnsafe(opened, Effect.void); + }), + port.onMessage((data) => { + bytesReceived += data.byteLength; + Queue.offerUnsafe(incoming, data); + }), + port.onClose(() => { + closeWith(socketError(new Socket.SocketCloseError({ code: 1000 }))); + }), + port.onError((error) => { + closeWith(socketError(new Socket.SocketReadError({ cause: error }))); + }), + port.onBufferedAmountLow(() => { + Queue.offerUnsafe(bufferedAmountLow, undefined); + }), + ]; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + for (const cleanup of cleanups) { + cleanup(); + } + port.close(); + }), + ); + + const awaitOpen = Deferred.await(opened).pipe(Effect.raceFirst(Deferred.await(closed))); + const receive = Effect.fn("WebRtcDataChannelConnection.receive")(function* () { + while (true) { + const takeFrame = Queue.take(incoming).pipe(Effect.raceFirst(Deferred.await(closed))); + const expiresAtMs = decoder.nextPartialExpiryAtMs(); + const frame = + expiresAtMs === null + ? yield* takeFrame + : yield* Clock.currentTimeMillis.pipe( + Effect.flatMap((nowMs) => + Effect.sleep(Duration.millis(Math.max(0, expiresAtMs - nowMs))).pipe( + Effect.andThen(Clock.currentTimeMillis), + Effect.flatMap((expiredAtMs) => + Effect.try({ + try: () => decoder.expirePartials(expiredAtMs), + catch: (cause) => socketError(new Socket.SocketReadError({ cause })), + }), + ), + Effect.tapError(failConnection), + Effect.andThen(Effect.never), + ), + ), + Effect.raceFirst(takeFrame), + ); + const nowMs = yield* Clock.currentTimeMillis; + const message = yield* Effect.try({ + try: () => decoder.push(frame, nowMs), + catch: (cause) => + cause instanceof WebRtcFramingError + ? cause + : new WebRtcFramingError( + "invalid-header", + "WebRTC framing decoder failed unexpectedly.", + ), + }).pipe( + Effect.tapError((error) => + Effect.logWarning("WebRTC framing rejected a DataChannel frame.").pipe( + Effect.annotateLogs({ "webrtc.framing.error_code": error.code }), + ), + ), + Effect.mapError((cause) => socketError(new Socket.SocketReadError({ cause }))), + Effect.tapError(failConnection), + ); + if (message !== null) { + return message; + } + } + }); + + const sendFrames = (frames: ReadonlyArray) => + writeLock.withPermits(1)( + Effect.gen(function* () { + for (const frame of frames) { + while (port.bufferedAmount() > BUFFERED_AMOUNT_HIGH_BYTES) { + yield* Queue.take(bufferedAmountLow).pipe(Effect.raceFirst(Deferred.await(closed))); + } + yield* Effect.try({ + try: () => { + port.send(frame); + bytesSent += frame.byteLength; + }, + catch: (cause) => socketError(new Socket.SocketWriteError({ cause })), + }); + } + }), + ); + + const sendControl = (kind: "binding" | "binding-ack", payload: Uint8Array) => + Effect.try({ + try: () => encodeWebRtcMessage({ kind, messageId: 0, payload }), + catch: (cause) => socketError(new Socket.SocketWriteError({ cause })), + }).pipe(Effect.flatMap(sendFrames)); + + const expectControl = Effect.fn("WebRtcDataChannelConnection.expectControl")(function* ( + expected: "binding" | "binding-ack", + ) { + const message = yield* receive(); + if (message.kind !== expected) { + return yield* socketError( + new Socket.SocketReadError({ cause: new Error(`Expected ${expected} control frame.`) }), + ); + } + return message.payload; + }); + + const socket = Socket.make({ + runRaw: (handler, options) => + Effect.gen(function* () { + yield* awaitOpen; + if (options?.onOpen !== undefined) { + yield* options.onOpen; + } + while (true) { + const message: DecodedWebRtcMessage = yield* receive(); + if (message.kind !== "rpc") { + const error = socketError( + new Socket.SocketReadError({ + cause: new Error("Unexpected WebRTC control frame after binding."), + }), + ); + yield* failConnection(error); + return yield* error; + } + const handled = handler(message.payload); + if (handled !== undefined) { + yield* handled; + } + } + }), + writer: Effect.succeed((chunk) => { + if (Socket.isCloseEvent(chunk)) { + return Effect.sync(() => port.close()); + } + const payload = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk; + return Effect.try({ + try: () => { + const messageId = nextMessageId; + nextMessageId = nextMessageId === 0xffff_ffff ? 1 : nextMessageId + 1; + return encodeWebRtcMessage({ kind: "rpc", messageId, payload }); + }, + catch: (cause) => socketError(new Socket.SocketWriteError({ cause })), + }).pipe(Effect.flatMap(sendFrames)); + }), + }); + + return { + awaitOpen, + sendBinding: (payload) => sendControl("binding", payload), + awaitBinding: expectControl("binding"), + sendBindingAck: sendControl("binding-ack", new Uint8Array()), + awaitBindingAck: expectControl("binding-ack").pipe(Effect.asVoid), + socket, + closed: Deferred.await(closed), + close: Effect.sync(() => port.close()), + stats: Effect.sync(() => ({ bytesSent, bytesReceived })), + } satisfies WebRtcDataChannelConnection; + }, +); diff --git a/packages/shared/src/webrtcFraming.test.ts b/packages/shared/src/webrtcFraming.test.ts new file mode 100644 index 000000000..f3075af98 --- /dev/null +++ b/packages/shared/src/webrtcFraming.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + encodeWebRtcMessage, + WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES, + WEBRTC_RPC_MAX_MESSAGE_BYTES, + WebRtcFramingError, + WebRtcMessageReassembler, +} from "./webrtcFraming.ts"; + +function reassemble(frames: ReadonlyArray) { + const reassembler = new WebRtcMessageReassembler(); + let result = null; + for (const frame of frames) { + result = reassembler.push(frame, 0); + } + return result; +} + +describe("WebRTC RPC framing", () => { + it("fragments and reassembles a large RPC message", () => { + const payload = new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES * 3 + 41); + payload.fill(77); + const frames = encodeWebRtcMessage({ kind: "rpc", messageId: 1, payload }); + + expect(frames).toHaveLength(4); + expect(reassemble(frames)).toEqual({ kind: "rpc", messageId: 1, payload }); + }); + + it("reassembles sequential messages", () => { + const decoder = new WebRtcMessageReassembler(); + const first = new TextEncoder().encode("first"); + const second = new TextEncoder().encode("second"); + + expect( + decoder.push(encodeWebRtcMessage({ kind: "rpc", messageId: 1, payload: first })[0]!, 0), + ).toEqual({ kind: "rpc", messageId: 1, payload: first }); + expect( + decoder.push(encodeWebRtcMessage({ kind: "rpc", messageId: 2, payload: second })[0]!, 0), + ).toEqual({ kind: "rpc", messageId: 2, payload: second }); + }); + + it("rejects malformed, overlapping, duplicate, and oversized frames", () => { + const payload = new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES + 5); + const frames = encodeWebRtcMessage({ kind: "rpc", messageId: 5, payload }); + const malformed = frames[0]!.slice(0, 8); + expect(() => new WebRtcMessageReassembler().push(malformed, 0)).toThrowError( + WebRtcFramingError, + ); + + const overlapping = frames[1]!.slice(); + new DataView(overlapping.buffer).setUint32(16, 1); + const overlapDecoder = new WebRtcMessageReassembler(); + overlapDecoder.push(frames[0]!, 0); + expect(() => overlapDecoder.push(overlapping, 0)).toThrowError(WebRtcFramingError); + + const duplicateDecoder = new WebRtcMessageReassembler(); + for (const frame of frames) { + duplicateDecoder.push(frame, 0); + } + expect(() => duplicateDecoder.push(frames[0]!, 0)).toThrowError(WebRtcFramingError); + + expect(() => + encodeWebRtcMessage({ + kind: "rpc", + messageId: 9, + payload: new Uint8Array(WEBRTC_RPC_MAX_MESSAGE_BYTES + 1), + }), + ).toThrowError(WebRtcFramingError); + }); + + it("expires partial messages", () => { + const decoder = new WebRtcMessageReassembler({ partialTtlMs: 10 }); + const partial = encodeWebRtcMessage({ + kind: "rpc", + messageId: 1, + payload: new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES + 1), + }); + const next = encodeWebRtcMessage({ + kind: "rpc", + messageId: 2, + payload: new Uint8Array([1]), + }); + + expect(decoder.push(partial[0]!, 0)).toBeNull(); + expect(() => decoder.push(next[0]!, 11)).toThrowError(WebRtcFramingError); + }); +}); diff --git a/packages/shared/src/webrtcFraming.ts b/packages/shared/src/webrtcFraming.ts new file mode 100644 index 000000000..d3628758d --- /dev/null +++ b/packages/shared/src/webrtcFraming.ts @@ -0,0 +1,344 @@ +export const WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES = 12 * 1024; +export const WEBRTC_RPC_MAX_MESSAGE_BYTES = 8 * 1024 * 1024; +export const WEBRTC_RPC_MAX_PARTIAL_MESSAGES = 8; +export const WEBRTC_RPC_PARTIAL_TTL_MS = 30_000; +export const WEBRTC_BINDING_MAX_BYTES = 4 * 1024; + +const FRAME_MAGIC = 0x54335243; +const FRAME_VERSION = 1; +const FRAME_HEADER_BYTES = 20; +const START_FLAG = 1; +const END_FLAG = 2; +const CONTROL_FLAG = 4; +const KNOWN_FLAGS = START_FLAG | END_FLAG | CONTROL_FLAG; +const COMPLETED_MESSAGE_IDS_LIMIT = 64; + +export type WebRtcFrameKind = "binding" | "binding-ack" | "rpc"; + +const FRAME_KIND_TO_CODE = { + binding: 1, + "binding-ack": 2, + rpc: 3, +} as const satisfies Readonly>; + +export interface DecodedWebRtcMessage { + readonly kind: WebRtcFrameKind; + readonly messageId: number; + readonly payload: Uint8Array; +} + +export type WebRtcFramingErrorCode = + | "frame-too-small" + | "invalid-header" + | "invalid-kind" + | "invalid-flags" + | "fragment-too-large" + | "message-too-large" + | "invalid-bounds" + | "missing-start" + | "duplicate-message" + | "too-many-partials" + | "partial-expired" + | "overlapping-fragment" + | "mismatched-message" + | "invalid-control-frame"; + +export class WebRtcFramingError extends Error { + readonly code: WebRtcFramingErrorCode; + + constructor(code: WebRtcFramingErrorCode, message: string) { + super(message); + this.name = "WebRtcFramingError"; + this.code = code; + } +} + +interface PartialMessage { + readonly kind: WebRtcFrameKind; + readonly totalLength: number; + readonly createdAtMs: number; + readonly chunks: Array; + receivedLength: number; +} + +function frameKindFromCode(code: number): WebRtcFrameKind { + switch (code) { + case 1: + return "binding"; + case 2: + return "binding-ack"; + case 3: + return "rpc"; + default: + throw new WebRtcFramingError("invalid-kind", "Unknown WebRTC frame kind."); + } +} + +function makeFrame(input: { + readonly kind: WebRtcFrameKind; + readonly messageId: number; + readonly totalLength: number; + readonly offset: number; + readonly payload: Uint8Array; + readonly start: boolean; + readonly end: boolean; +}): Uint8Array { + const frame = new Uint8Array(FRAME_HEADER_BYTES + input.payload.byteLength); + const view = new DataView(frame.buffer); + view.setUint32(0, FRAME_MAGIC); + view.setUint8(4, FRAME_VERSION); + view.setUint8(5, FRAME_KIND_TO_CODE[input.kind]); + view.setUint8( + 6, + (input.start ? START_FLAG : 0) | + (input.end ? END_FLAG : 0) | + (input.kind === "rpc" ? 0 : CONTROL_FLAG), + ); + view.setUint8(7, 0); + view.setUint32(8, input.messageId); + view.setUint32(12, input.totalLength); + view.setUint32(16, input.offset); + frame.set(input.payload, FRAME_HEADER_BYTES); + return frame; +} + +export function encodeWebRtcMessage(input: { + readonly kind: WebRtcFrameKind; + readonly messageId: number; + readonly payload: Uint8Array; + readonly fragmentPayloadBytes?: number; +}): ReadonlyArray { + const fragmentPayloadBytes = input.fragmentPayloadBytes ?? WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES; + if (fragmentPayloadBytes <= 0 || fragmentPayloadBytes > WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES) { + throw new WebRtcFramingError( + "fragment-too-large", + "WebRTC fragment payload size is outside the supported range.", + ); + } + if (input.payload.byteLength > WEBRTC_RPC_MAX_MESSAGE_BYTES) { + throw new WebRtcFramingError("message-too-large", "WebRTC message exceeds the size limit."); + } + if (input.kind !== "rpc") { + if ( + input.messageId !== 0 || + input.payload.byteLength > WEBRTC_BINDING_MAX_BYTES || + (input.kind === "binding-ack" && input.payload.byteLength !== 0) + ) { + throw new WebRtcFramingError( + "invalid-control-frame", + "WebRTC control frame has an invalid shape.", + ); + } + return [ + makeFrame({ + kind: input.kind, + messageId: 0, + totalLength: input.payload.byteLength, + offset: 0, + payload: input.payload, + start: true, + end: true, + }), + ]; + } + if (input.messageId === 0) { + throw new WebRtcFramingError("invalid-header", "WebRTC RPC message ID must be non-zero."); + } + if (input.payload.byteLength === 0) { + return [ + makeFrame({ + kind: "rpc", + messageId: input.messageId, + totalLength: 0, + offset: 0, + payload: input.payload, + start: true, + end: true, + }), + ]; + } + + const frames: Array = []; + for (let offset = 0; offset < input.payload.byteLength; offset += fragmentPayloadBytes) { + const endOffset = Math.min(offset + fragmentPayloadBytes, input.payload.byteLength); + frames.push( + makeFrame({ + kind: "rpc", + messageId: input.messageId, + totalLength: input.payload.byteLength, + offset, + payload: input.payload.subarray(offset, endOffset), + start: offset === 0, + end: endOffset === input.payload.byteLength, + }), + ); + } + return frames; +} + +export class WebRtcMessageReassembler { + readonly #maxMessageBytes: number; + readonly #maxPartialMessages: number; + readonly #partialTtlMs: number; + readonly #partials = new Map(); + readonly #completedMessageIds = new Set(); + readonly #completedMessageOrder: Array = []; + + constructor(options?: { + readonly maxMessageBytes?: number; + readonly maxPartialMessages?: number; + readonly partialTtlMs?: number; + }) { + this.#maxMessageBytes = options?.maxMessageBytes ?? WEBRTC_RPC_MAX_MESSAGE_BYTES; + this.#maxPartialMessages = options?.maxPartialMessages ?? WEBRTC_RPC_MAX_PARTIAL_MESSAGES; + this.#partialTtlMs = options?.partialTtlMs ?? WEBRTC_RPC_PARTIAL_TTL_MS; + } + + nextPartialExpiryAtMs(): number | null { + let nextExpiryAtMs: number | null = null; + for (const partial of this.#partials.values()) { + const expiresAtMs = partial.createdAtMs + this.#partialTtlMs; + if (nextExpiryAtMs === null || expiresAtMs < nextExpiryAtMs) { + nextExpiryAtMs = expiresAtMs; + } + } + return nextExpiryAtMs; + } + + expirePartials(nowMs: number): void { + let expired = false; + for (const [messageId, partial] of this.#partials) { + if (nowMs >= partial.createdAtMs + this.#partialTtlMs) { + this.#partials.delete(messageId); + expired = true; + } + } + if (expired) { + throw new WebRtcFramingError( + "partial-expired", + "WebRTC partial message exceeded its lifetime.", + ); + } + } + + push(frame: Uint8Array, nowMs: number): DecodedWebRtcMessage | null { + this.expirePartials(nowMs); + if (frame.byteLength < FRAME_HEADER_BYTES) { + throw new WebRtcFramingError("frame-too-small", "WebRTC frame is shorter than its header."); + } + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); + if ( + view.getUint32(0) !== FRAME_MAGIC || + view.getUint8(4) !== FRAME_VERSION || + view.getUint8(7) !== 0 + ) { + throw new WebRtcFramingError("invalid-header", "WebRTC frame header is invalid."); + } + const kind = frameKindFromCode(view.getUint8(5)); + const flags = view.getUint8(6); + if ((flags & ~KNOWN_FLAGS) !== 0) { + throw new WebRtcFramingError("invalid-flags", "WebRTC frame has unknown flags."); + } + const start = (flags & START_FLAG) !== 0; + const end = (flags & END_FLAG) !== 0; + const control = (flags & CONTROL_FLAG) !== 0; + if (control !== (kind !== "rpc")) { + throw new WebRtcFramingError("invalid-flags", "WebRTC frame control flag is invalid."); + } + const messageId = view.getUint32(8); + const totalLength = view.getUint32(12); + const offset = view.getUint32(16); + const payload = frame.subarray(FRAME_HEADER_BYTES); + if (payload.byteLength > WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES) { + throw new WebRtcFramingError("fragment-too-large", "WebRTC fragment exceeds the size limit."); + } + if (totalLength > this.#maxMessageBytes) { + throw new WebRtcFramingError("message-too-large", "WebRTC message exceeds the size limit."); + } + if (offset > totalLength || payload.byteLength > totalLength - offset) { + throw new WebRtcFramingError("invalid-bounds", "WebRTC fragment is out of bounds."); + } + const reachesEnd = offset + payload.byteLength === totalLength; + if (start !== (offset === 0) || end !== reachesEnd) { + throw new WebRtcFramingError("invalid-flags", "WebRTC fragment boundary flags are invalid."); + } + + if (kind !== "rpc") { + if ( + messageId !== 0 || + !start || + !end || + totalLength > WEBRTC_BINDING_MAX_BYTES || + (kind === "binding-ack" && totalLength !== 0) + ) { + throw new WebRtcFramingError( + "invalid-control-frame", + "WebRTC control frame has an invalid shape.", + ); + } + return { kind, messageId, payload: payload.slice() }; + } + if (messageId === 0) { + throw new WebRtcFramingError("invalid-header", "WebRTC RPC message ID must be non-zero."); + } + if (this.#completedMessageIds.has(messageId)) { + throw new WebRtcFramingError("duplicate-message", "WebRTC message ID was already completed."); + } + + let partial = this.#partials.get(messageId); + if (partial === undefined) { + if (!start) { + throw new WebRtcFramingError("missing-start", "WebRTC message is missing its start frame."); + } + if (this.#partials.size >= this.#maxPartialMessages) { + throw new WebRtcFramingError("too-many-partials", "WebRTC has too many partial messages."); + } + partial = { + kind, + totalLength, + createdAtMs: nowMs, + chunks: [], + receivedLength: 0, + }; + this.#partials.set(messageId, partial); + } else { + if (start) { + throw new WebRtcFramingError("duplicate-message", "WebRTC message has a duplicate start."); + } + if (partial.kind !== kind || partial.totalLength !== totalLength) { + throw new WebRtcFramingError( + "mismatched-message", + "WebRTC fragment does not match its partial message.", + ); + } + } + if (offset !== partial.receivedLength) { + throw new WebRtcFramingError( + "overlapping-fragment", + "WebRTC fragment overlaps or skips existing message data.", + ); + } + partial.chunks.push(payload.slice()); + partial.receivedLength += payload.byteLength; + if (!end) { + return null; + } + + this.#partials.delete(messageId); + const message = new Uint8Array(totalLength); + let writeOffset = 0; + for (const chunk of partial.chunks) { + message.set(chunk, writeOffset); + writeOffset += chunk.byteLength; + } + this.#completedMessageIds.add(messageId); + this.#completedMessageOrder.push(messageId); + if (this.#completedMessageOrder.length > COMPLETED_MESSAGE_IDS_LIMIT) { + const oldest = this.#completedMessageOrder.shift(); + if (oldest !== undefined) { + this.#completedMessageIds.delete(oldest); + } + } + return { kind: "rpc", messageId, payload: message }; + } +} diff --git a/packages/shared/src/webrtcPeerState.test.ts b/packages/shared/src/webrtcPeerState.test.ts new file mode 100644 index 000000000..6856c4cc4 --- /dev/null +++ b/packages/shared/src/webrtcPeerState.test.ts @@ -0,0 +1,24 @@ +import { expect, it } from "vite-plus/test"; + +import { + isTerminalWebRtcPeerConnectionState, + type WebRtcPeerConnectionState, +} from "./webrtcPeerState.ts"; + +it("keeps transient WebRTC peer states alive", () => { + const states: ReadonlyArray = [ + "new", + "connecting", + "connected", + "disconnected", + ]; + + for (const state of states) { + expect(isTerminalWebRtcPeerConnectionState(state)).toBe(false); + } +}); + +it("closes WebRTC peers only for terminal states", () => { + expect(isTerminalWebRtcPeerConnectionState("failed")).toBe(true); + expect(isTerminalWebRtcPeerConnectionState("closed")).toBe(true); +}); diff --git a/packages/shared/src/webrtcPeerState.ts b/packages/shared/src/webrtcPeerState.ts new file mode 100644 index 000000000..4e47a3060 --- /dev/null +++ b/packages/shared/src/webrtcPeerState.ts @@ -0,0 +1,11 @@ +export type WebRtcPeerConnectionState = + | "new" + | "connecting" + | "connected" + | "disconnected" + | "failed" + | "closed"; + +export function isTerminalWebRtcPeerConnectionState(state: string): boolean { + return state === "failed" || state === "closed"; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c2809ffb..484a72368 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@clerk/expo': specifier: 4.2.0 version: 4.2.0(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.1(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) @@ -405,6 +408,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) @@ -506,6 +512,10 @@ importers: 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.3 + version: 0.24.3 apps/web: dependencies: @@ -1840,6 +1850,11 @@ packages: '@cloudflare/workers-types@5.20260726.1': resolution: {integrity: sha512-fKgRSm3sDmOdak1LGWehS4vSPSj7/zeu0NfmE62VPjBMqWgODcOGljYvq6A75sL+7YfY3iGFGb0jVEDYq+hlmw==} + '@config-plugins/react-native-webrtc@15.0.1': + resolution: {integrity: sha512-1/RSRnMOWPqHmEJCGEhElzFvkXUiNkGYsbAIxJl/3+t9OW2l26Qsjo8Ei7v/OMJ7g0Ek6qnKkjCxYrrLSfJi2A==} + peerDependencies: + expo: ^56 + '@distilled.cloud/aws@0.30.2': resolution: {integrity: sha512-Uw2yZf7PJ2ienrKG49HN1ajnke65mTtHBUrSb/3ykeZ/8cLaSgwVhPscHxSzGByY0TEtYcYKNNmfUVISgGQl9g==} peerDependencies: @@ -2687,6 +2702,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==} @@ -2962,6 +2985,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==} @@ -3557,9 +3583,36 @@ packages: resolution: {integrity: sha512-titLmukUt/h8ho7Svlf0xSBjoy2ccZKrXjpXpZCj+v6V4CJccC2KyP45BLSCMx8YIpifMyiDyUptM4+5sruKbQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + '@peculiar/asn1-cms@2.8.0': + resolution: {integrity: sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==} + + '@peculiar/asn1-csr@2.8.0': + resolution: {integrity: sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==} + + '@peculiar/asn1-ecc@2.8.0': + resolution: {integrity: sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==} + + '@peculiar/asn1-pfx@2.8.0': + resolution: {integrity: sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==} + + '@peculiar/asn1-pkcs8@2.8.0': + resolution: {integrity: sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==} + + '@peculiar/asn1-pkcs9@2.8.0': + resolution: {integrity: sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==} + + '@peculiar/asn1-rsa@2.8.0': + resolution: {integrity: sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==} + '@peculiar/asn1-schema@2.8.0': resolution: {integrity: sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==} + '@peculiar/asn1-x509-attr@2.8.0': + resolution: {integrity: sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==} + + '@peculiar/asn1-x509@2.8.0': + resolution: {integrity: sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==} + '@peculiar/json-schema@1.1.12': resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==} engines: {node: '>=8.0.0'} @@ -3571,6 +3624,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: @@ -4371,6 +4428,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==} @@ -4796,6 +4857,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==} @@ -5638,6 +5705,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'} @@ -6027,6 +6097,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'} @@ -6126,6 +6214,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==} @@ -7225,6 +7317,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'} @@ -7373,6 +7468,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==} @@ -7417,6 +7516,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==} @@ -7949,6 +8052,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + mediabunny@1.53.0: + resolution: {integrity: sha512-RjWe9yJr9MPvb6kX4gSu54fZD2w1yf6RyCS0Jmgaml+fFSqEr7+XuASXoRxSqJxgc+wIe+7TujXWcyj3evJ3Bg==} + memoize-one@5.2.1: resolution: {integrity: sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==} @@ -8243,6 +8349,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==} @@ -8266,6 +8375,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==} @@ -9030,6 +9143,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: @@ -9133,6 +9251,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==} @@ -9716,6 +9837,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'} @@ -9811,9 +9935,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'} @@ -10296,6 +10430,10 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + werift@0.24.3: + resolution: {integrity: sha512-crNnprG2bOFe6Pfv2mLLaQehpkfcldIfijp0YfjREfNZ50JzV13b74KYpMr7w6nY8LPJ2N4Kquo1imczWMG+sg==} + engines: {node: '>=16'} + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} @@ -11666,6 +11804,10 @@ snapshots: '@cloudflare/workers-types@5.20260726.1': {} + '@config-plugins/react-native-webrtc@15.0.1(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 @@ -12780,6 +12922,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 @@ -13002,6 +13153,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 @@ -13556,12 +13710,91 @@ snapshots: '@oxlint/plugins@1.68.0': {} + '@peculiar/asn1-cms@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-csr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-ecc@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-pfx@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-pkcs8@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-pkcs9@2.8.0': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-pfx': 2.8.0 + '@peculiar/asn1-pkcs8': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + '@peculiar/asn1-x509-attr': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-rsa@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.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 + '@peculiar/asn1-x509-attr@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.0 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + + '@peculiar/asn1-x509@2.8.0': + dependencies: + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/utils': 2.0.3 + asn1js: 3.0.10 + tslib: 2.8.1 + optional: true + '@peculiar/json-schema@1.1.12': dependencies: tslib: 2.8.1 @@ -13578,6 +13811,21 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 + '@peculiar/x509@1.14.3': + dependencies: + '@peculiar/asn1-cms': 2.8.0 + '@peculiar/asn1-csr': 2.8.0 + '@peculiar/asn1-ecc': 2.8.0 + '@peculiar/asn1-pkcs9': 2.8.0 + '@peculiar/asn1-rsa': 2.8.0 + '@peculiar/asn1-schema': 2.8.0 + '@peculiar/asn1-x509': 2.8.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 @@ -14549,6 +14797,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': {} @@ -14974,6 +15228,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': @@ -15979,6 +16241,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 @@ -16359,6 +16627,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 @@ -16443,6 +16720,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: {} @@ -17931,6 +18213,9 @@ snapshots: idb-keyval@6.2.1: optional: true + ieee754@1.2.1: + optional: true + ignore@5.3.2: {} ignore@7.0.5: {} @@ -18065,6 +18350,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: {} @@ -18093,6 +18383,9 @@ snapshots: isexe@4.0.0: {} + isobject@3.0.1: + optional: true + isomorphic.js@0.2.5: {} jake@10.9.4: @@ -18647,6 +18940,12 @@ snapshots: media-typer@1.1.0: {} + mediabunny@1.53.0: + 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: {} @@ -19107,6 +19406,8 @@ snapshots: ms@2.0.0: {} + ms@2.1.2: {} + ms@2.1.3: {} msgpackr-extract@3.0.4: @@ -19152,6 +19453,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: {} @@ -19987,6 +20294,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 @@ -20234,6 +20549,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: @@ -21018,6 +21336,9 @@ snapshots: throat@5.0.0: {} + thunky@1.1.0: + optional: true + timestring@6.0.0: {} tiny-async-pool@1.3.0: @@ -21089,8 +21410,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 @@ -21591,6 +21923,21 @@ snapshots: webpack-virtual-modules@0.6.2: {} + werift@0.24.3: + 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.53.0 + 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: {} diff --git a/scripts/build-desktop-artifact.ts b/scripts/build-desktop-artifact.ts index 6625ac39a..2e121f290 100644 --- a/scripts/build-desktop-artifact.ts +++ b/scripts/build-desktop-artifact.ts @@ -621,6 +621,7 @@ interface StagePackageJson { readonly main: string; readonly build: Record; readonly dependencies: Record; + readonly optionalDependencies: Record; readonly devDependencies: { readonly electron: string; }; @@ -1794,6 +1795,20 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( cause, }), }); + const resolvedServerOptionalDependencies = yield* Effect.try({ + try: () => + resolveCatalogDependencies( + serverPackageJson.optionalDependencies ?? {}, + workspaceCatalog, + "apps/server", + ), + catch: (cause) => + new DesktopBuildDependencyResolutionError({ + kind: "server-production", + manifestPath: "apps/server/package.json", + cause, + }), + }); const resolvedDesktopRuntimeDependencies = yield* Effect.try({ try: () => resolveDesktopRuntimeDependencies(desktopPackageJson.dependencies, workspaceCatalog), catch: (cause) => @@ -1941,10 +1956,10 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( ) : {}), }; - const stagePatchedDependencies = createStagePatchedDependencies( - workspacePatchedDependencies, - stageDependencies, - ); + const stagePatchedDependencies = createStagePatchedDependencies(workspacePatchedDependencies, { + ...stageDependencies, + ...resolvedServerOptionalDependencies, + }); const stagePackageJson: StagePackageJson = { name: "t3code", version: appVersion, @@ -1970,6 +1985,7 @@ const buildDesktopArtifact = Effect.fn("buildDesktopArtifact")(function* ( : undefined, ), dependencies: stageDependencies, + optionalDependencies: resolvedServerOptionalDependencies, devDependencies: { electron: electronVersion, }, From a35ae60ac75420135070f429c91a2e81256b8f77 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 10 Aug 2026 15:26:24 +0300 Subject: [PATCH 2/9] refactor(client-runtime): upgrade WebRTC after WebSocket readiness --- docs/internals/webrtc-rpc-fast-path.md | 3 +- .../client-runtime/src/rpc/client.test.ts | 53 ++++++++++++++++ packages/client-runtime/src/rpc/client.ts | 12 +++- .../client-runtime/src/rpc/session.test.ts | 33 +++++++++- packages/client-runtime/src/rpc/session.ts | 62 ++++++++++++------- 5 files changed, 135 insertions(+), 28 deletions(-) diff --git a/docs/internals/webrtc-rpc-fast-path.md b/docs/internals/webrtc-rpc-fast-path.md index 4099e9f92..0d5df2fe9 100644 --- a/docs/internals/webrtc-rpc-fast-path.md +++ b/docs/internals/webrtc-rpc-fast-path.md @@ -5,7 +5,8 @@ T3 Code can run `WsRpcGroup` over one reliable, ordered WebRTC DataChannel named mode. The client first establishes and authenticates the normal environment WebSocket. -It reads `server.getConfig` there and negotiates WebRTC through +It reads `server.getConfig` there and exposes the WebSocket session immediately. +WebRTC negotiation then runs as a background upgrade through `transport.webrtc.negotiate` on that same socket. The server binds the new DataChannel to the authenticated WebSocket with a short-lived attempt ID and a one-time random token. The client selects WebRTC only after `server.probe` and a diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 507d137ca..7870bad21 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -185,6 +185,59 @@ describe("environment RPC", () => { }), ); + it.effect("switches durable subscriptions when the active session upgrades transport", () => + Effect.gen(function* () { + const subscriptions: string[] = []; + const websocketClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("websocket"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const webRtcClient = { + [WS_METHODS.subscribeTerminalEvents]: () => { + subscriptions.push("webrtc"); + return Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const selectedClient = yield* SubscriptionRef.make(websocketClient); + const transport = yield* SubscriptionRef.make<"websocket" | "webrtc">("websocket"); + const upgradingSession = { + get client() { + return SubscriptionRef.getUnsafe(selectedClient); + }, + initialConfig: Effect.never, + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + get transport() { + return SubscriptionRef.getUnsafe(transport); + }, + transportChanges: SubscriptionRef.changes(transport), + } satisfies RpcSession.RpcSession; + const { activeSession, supervisor } = yield* makeHarness(); + + const subscriptionFiber = yield* subscribe(WS_METHODS.subscribeTerminalEvents, {}).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + yield* SubscriptionRef.set(activeSession, Option.some(upgradingSession)); + for (let attempt = 0; attempt < 100 && subscriptions.length < 1; attempt += 1) { + yield* Effect.yieldNow; + } + + yield* SubscriptionRef.set(selectedClient, webRtcClient); + yield* SubscriptionRef.set(transport, "webrtc"); + for (let attempt = 0; attempt < 100 && subscriptions.length < 2; attempt += 1) { + yield* Effect.yieldNow; + } + yield* Fiber.interrupt(subscriptionFiber); + + expect(subscriptions).toEqual(["websocket", "webrtc"]); + }), + ); + it.effect("keeps durable subscriptions alive across a transport failure and new session", () => Effect.gen(function* () { const subscriptions: string[] = []; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index d8f6c0f8c..56a897430 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -193,7 +193,17 @@ export function subscribeDynamicRequest Stream.succeed(Option.none()), + onSome: (session) => + session.transportChanges === undefined + ? Stream.succeed(Option.some(session)) + : session.transportChanges.pipe(Stream.map(() => Option.some(session))), + }), + ), + ); const sessions = options?.resubscribe === undefined ? sessionChanges diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index b8e34df46..85e8b4346 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -10,8 +10,10 @@ import * as Effect from "effect/Effect"; import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import * as Socket from "effect/unstable/socket/Socket"; import { @@ -451,7 +453,7 @@ describe("RpcSessionFactory", () => { }), ); - it.effect("selects WebRTC after its RPC probe and closes it with the control WebSocket", () => + it.effect("reaches WebSocket readiness before selecting WebRTC", () => Effect.gen(function* () { const rtc = yield* makeRtcHarness(); const { factory, sockets } = yield* makeFactory(rtc.peerFactory); @@ -461,9 +463,21 @@ describe("RpcSessionFactory", () => { socket.open(); yield* completeInitialConfig(socket, ENCODED_WEBRTC_SERVER_CONFIG); - yield* completeWebRtcSignaling(socket); yield* Fiber.join(readyFiber); + expect(session.transport).toBe("websocket"); + const transportChanges = session.transportChanges; + if (transportChanges === undefined) { + return yield* Effect.die(new Error("Expected session transport changes.")); + } + const upgraded = yield* transportChanges.pipe( + Stream.filter((transport) => transport === "webrtc"), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + yield* completeWebRtcSignaling(socket); + expect(yield* Fiber.join(upgraded)).toBe("webrtc"); expect(session.transport).toBe("webrtc"); expect(yield* Queue.take(rtc.rtcRequests)).toBe(WS_METHODS.serverProbe); expect(yield* Queue.take(rtc.rtcRequests)).toBe(WS_METHODS.serverGetConfig); @@ -489,8 +503,21 @@ describe("RpcSessionFactory", () => { firstSocket.open(); yield* completeInitialConfig(firstSocket, ENCODED_WEBRTC_SERVER_CONFIG); - yield* completeWebRtcSignaling(firstSocket); yield* Fiber.join(firstReady); + + expect(firstSession.transport).toBe("websocket"); + const transportChanges = firstSession.transportChanges; + if (transportChanges === undefined) { + return yield* Effect.die(new Error("Expected session transport changes.")); + } + const upgraded = yield* transportChanges.pipe( + Stream.filter((transport) => transport === "webrtc"), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + yield* completeWebRtcSignaling(firstSocket); + expect(yield* Fiber.join(upgraded)).toBe("webrtc"); expect(firstSession.transport).toBe("webrtc"); const firstClosed = yield* Effect.flip(firstSession.closed).pipe(Effect.forkChild); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index a919938ba..e381c68ca 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -10,6 +10,8 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; import * 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"; @@ -40,6 +42,7 @@ export interface RpcSession { readonly probe: Effect.Effect; readonly closed: Effect.Effect; readonly transport?: "websocket" | "webrtc"; + readonly transportChanges?: Stream.Stream<"websocket" | "webrtc">; } export class RpcSessionFactory extends Context.Service< @@ -144,17 +147,16 @@ export const make = Effect.gen(function* () { readonly close: Effect.Effect; } - const selectionComplete = yield* Deferred.make(); - let selected: SelectedTransport = { + const upgradeComplete = yield* Deferred.make(); + const selected = yield* SubscriptionRef.make({ kind: "websocket", client: controlClient, initialConfig: controlInitialConfig, closed: Deferred.await(disconnected), close: Effect.void, - }; + }); const selectWebRtc = Effect.fn("RpcSession.selectWebRtc")(function* ( - config: ServerConfig, peerFactory: WebRtcPeerFactoryService, capability: WebRtcRpcFastPathCapability, ) { @@ -197,17 +199,18 @@ export const make = Effect.gen(function* () { }), ), ); - selected = { + yield* SubscriptionRef.set(selected, { kind: "webrtc", client: fastPath.client, initialConfig: Effect.succeed(fastPath.initialConfig), closed: rtcClosed, close: fastPath.close, - }; + }); }); - const selectTransport = Effect.gen(function* () { - const config = yield* controlInitialConfig; + const startTransportUpgrade = Effect.fn("RpcSession.startTransportUpgrade")(function* ( + config: ServerConfig, + ) { const capability = config.environment.capabilities.webRtcRpcFastPath; let fallbackReason: "capability-absent" | "platform-absent" | "cooldown" | null = null; if (capability === undefined) { @@ -219,7 +222,11 @@ export const make = Effect.gen(function* () { if (webRtcCooldown.isActive(connection.environmentId, nowMs)) { fallbackReason = "cooldown"; } else { - yield* selectWebRtc(config, webRtcPeerFactory, capability); + yield* selectWebRtc(webRtcPeerFactory, capability).pipe( + Effect.raceFirst(Deferred.await(disconnected)), + Effect.ensuring(Deferred.succeed(upgradeComplete, undefined)), + Effect.forkIn(scope), + ); } } if (fallbackReason !== null) { @@ -229,51 +236,60 @@ export const make = Effect.gen(function* () { "webrtc.fallback.reason": fallbackReason, }), ); + yield* Deferred.succeed(upgradeComplete, undefined); } - yield* Deferred.succeed(selectionComplete, undefined); }); const ready = yield* Effect.cached( Deferred.await(connected).pipe( - Effect.andThen(selectTransport), + Effect.andThen(controlInitialConfig), + Effect.tap(startTransportUpgrade), + Effect.asVoid, Effect.raceFirst(Deferred.await(disconnected)), ), ); - const probe = Effect.suspend(() => - selected.initialConfig.pipe( + const probe = Effect.suspend(() => { + const transport = SubscriptionRef.getUnsafe(selected); + return transport.initialConfig.pipe( Effect.flatMap((config) => (config.environment.capabilities.connectionProbe === true - ? selected.client[WS_METHODS.serverProbe]({}) - : selected.client[WS_METHODS.serverGetConfig]({}) + ? transport.client[WS_METHODS.serverProbe]({}) + : transport.client[WS_METHODS.serverGetConfig]({}) ).pipe(Effect.mapError(mapSessionRpcError)), ), Effect.asVoid, Effect.withSpan("clientRuntime.connection.rpcSession.probe"), - ), - ); + ); + }); const closed = Effect.raceFirst( Deferred.await(disconnected).pipe( - Effect.tapError(() => Effect.suspend(() => selected.close)), + Effect.tapError(() => Effect.suspend(() => SubscriptionRef.getUnsafe(selected).close)), ), - Deferred.await(selectionComplete).pipe( + Deferred.await(upgradeComplete).pipe( Effect.andThen( - Effect.suspend(() => (selected.kind === "webrtc" ? selected.closed : Effect.never)), + Effect.suspend(() => { + const transport = SubscriptionRef.getUnsafe(selected); + return transport.kind === "webrtc" ? transport.closed : Effect.never; + }), ), ), ); return { get client() { - return selected.client; + return SubscriptionRef.getUnsafe(selected).client; }, get initialConfig() { - return selected.initialConfig; + return SubscriptionRef.getUnsafe(selected).initialConfig; }, ready, probe, closed, get transport() { - return selected.kind; + return SubscriptionRef.getUnsafe(selected).kind; }, + transportChanges: SubscriptionRef.changes(selected).pipe( + Stream.map((transport) => transport.kind), + ), } satisfies RpcSession; }); From d20081821d55b099786ef62ae7a4d24588437830 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 10 Aug 2026 17:17:02 +0300 Subject: [PATCH 3/9] fix(server): buffer early WebRTC binding frames --- apps/server/src/webrtc/WebRtcPeer.test.ts | 36 +++++++++- apps/server/src/webrtc/WebRtcPeer.ts | 81 ++++++++++++++++++++--- 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/apps/server/src/webrtc/WebRtcPeer.test.ts b/apps/server/src/webrtc/WebRtcPeer.test.ts index 9c3a3bdcb..b9588bc76 100644 --- a/apps/server/src/webrtc/WebRtcPeer.test.ts +++ b/apps/server/src/webrtc/WebRtcPeer.test.ts @@ -2,6 +2,7 @@ import { WebRtcBindingFrame } from "@t3tools/contracts"; import { makeWebRtcDataChannelConnection } from "@t3tools/shared/webrtcDataChannel"; import { NodeServices } from "@effect/platform-node"; import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -14,7 +15,7 @@ import * as RpcSerialization from "effect/unstable/rpc/RpcSerialization"; import * as RpcServer from "effect/unstable/rpc/RpcServer"; import * as Socket from "effect/unstable/socket/Socket"; import * as SocketServer from "effect/unstable/socket/SocketServer"; -import { RTCPeerConnection } from "werift"; +import { Event, RTCPeerConnection } from "werift"; import { makeSingleSocketServer } from "./SingleSocketServer.ts"; import { makeWebRtcFastPathController } from "./WebRtcFastPathController.ts"; @@ -149,3 +150,36 @@ it.effect("binds and fragments RPC messages over a real DataChannel", () => ).toEqual(["first", "second", "third"]); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + +it.effect("buffers a binding frame sent before the server socket attaches", () => + Effect.gen(function* () { + const stateChanged = new Event<["connecting" | "open" | "closing" | "closed"]>(); + const messages = new Event<[string | Buffer]>(); + const readyState: "open" = "open"; + const channel = { + label: "t3-rpc-v1", + ordered: true, + readyState, + bufferedAmount: 0, + bufferedAmountLowThreshold: 0, + send: () => undefined, + close: () => stateChanged.execute("closed"), + stateChanged, + onMessage: messages, + error: new Event<[Error]>(), + bufferedAmountLow: new Event<[]>(), + }; + const port = weriftDataChannelPort(channel); + messages.execute("binding-before-listener"); + + const received = yield* Deferred.make(); + const removeListener = port.onMessage((message) => { + Deferred.doneUnsafe(received, Effect.succeed(message)); + }); + yield* Effect.addFinalizer(() => Effect.sync(removeListener)); + + expect(new TextDecoder().decode(yield* Deferred.await(received))).toBe( + "binding-before-listener", + ); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/webrtc/WebRtcPeer.ts b/apps/server/src/webrtc/WebRtcPeer.ts index a500d0dfc..22feae24f 100644 --- a/apps/server/src/webrtc/WebRtcPeer.ts +++ b/apps/server/src/webrtc/WebRtcPeer.ts @@ -11,6 +11,9 @@ import { isTerminalWebRtcPeerConnectionState } from "@t3tools/shared/webrtcPeerS import type { WebRtcUdpPortRange } from "./config.ts"; +const EARLY_DATA_CHANNEL_MESSAGE_LIMIT = 4; +const EARLY_DATA_CHANNEL_BYTES_LIMIT = 64 * 1024; + export interface ServerWebRtcPeer { readonly acceptOffer: (offerSdp: string) => Effect.Effect; readonly takeDataChannel: Effect.Effect; @@ -44,12 +47,59 @@ export interface ServerWebRtcRuntime { ) => Effect.Effect; } +type WeriftDataChannelAdapter = Pick< + RTCDataChannel, + | "label" + | "ordered" + | "readyState" + | "bufferedAmount" + | "bufferedAmountLowThreshold" + | "send" + | "close" + | "stateChanged" + | "onMessage" + | "error" + | "bufferedAmountLow" +>; + function iceCandidateType(candidate: string): string { const match = /(?:^|\s)typ\s+(host|srflx|prflx|relay)(?:\s|$)/i.exec(candidate); return match?.[1]?.toLowerCase() ?? "unknown"; } -export function weriftDataChannelPort(channel: RTCDataChannel): WebRtcDataChannelPort { +export function weriftDataChannelPort(channel: WeriftDataChannelAdapter): 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_DATA_CHANNEL_MESSAGE_LIMIT || + earlyMessageBytes + data.byteLength > EARLY_DATA_CHANNEL_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, @@ -59,7 +109,10 @@ export function weriftDataChannelPort(channel: RTCDataChannel): WebRtcDataChanne channel.bufferedAmountLowThreshold = bytes; }, send: (data) => channel.send(NodeBuffer.Buffer.from(data)), - close: () => channel.close(), + close: () => { + closeMessageSubscription(); + channel.close(); + }, onOpen: (listener) => { const subscription = channel.stateChanged.subscribe((state) => { if (state === "open") listener(); @@ -67,18 +120,26 @@ export function weriftDataChannelPort(channel: RTCDataChannel): WebRtcDataChanne return subscription.unSubscribe; }, onMessage: (listener) => { - const subscription = channel.onMessage.subscribe((message) => { - if (typeof message === "string") { - listener(new TextEncoder().encode(message)); - return; + if (messageListener !== null) { + throw new Error("WebRTC DataChannel already has a message listener."); + } + messageListener = listener; + for (const message of earlyMessages.splice(0)) { + listener(message); + } + earlyMessageBytes = 0; + return () => { + if (messageListener === listener) { + messageListener = null; } - listener(Uint8Array.from(message)); - }); - return subscription.unSubscribe; + }; }, onClose: (listener) => { const subscription = channel.stateChanged.subscribe((state) => { - if (state === "closed") listener(); + if (state === "closed") { + closeMessageSubscription(); + listener(); + } }); return subscription.unSubscribe; }, From 51a5f0cf26c6b7144386380ce2214ba105a66719 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 10 Aug 2026 19:20:04 +0300 Subject: [PATCH 4/9] refactor(rpc): support configurable TURN servers --- .env.example | 8 + apps/mobile/src/connection/platform.ts | 19 +- apps/server/src/cli/config.test.ts | 34 +++- apps/server/src/cli/config.ts | 130 ++++++++++--- apps/server/src/config.ts | 3 +- apps/server/src/server.test.ts | 11 +- .../webrtc/WebRtcFastPathController.test.ts | 65 ++++++- .../src/webrtc/WebRtcFastPathController.ts | 85 +++++---- apps/server/src/webrtc/WebRtcPeer.test.ts | 10 +- apps/server/src/webrtc/WebRtcPeer.ts | 57 ++++-- apps/server/src/webrtc/config.ts | 26 ++- apps/server/src/ws.ts | 2 +- apps/web/src/connection/platform.ts | 16 +- docs/internals/webrtc-rpc-fast-path.md | 31 +-- .../src/platform/capabilities.ts | 3 +- .../client-runtime/src/rpc/session.test.ts | 3 +- .../src/rpc/webrtc/FastPathNegotiator.ts | 77 +++++--- .../src/rpc/webrtc/WebRtcPeer.test.ts | 59 +++++- .../src/rpc/webrtc/WebRtcPeer.ts | 19 +- packages/contracts/src/webrtc.ts | 16 +- .../shared/src/webrtcCandidatePolicy.test.ts | 102 +++++++--- packages/shared/src/webrtcCandidatePolicy.ts | 142 +++++++++----- packages/shared/src/webrtcDataChannel.test.ts | 8 +- packages/shared/src/webrtcDataChannel.ts | 48 +++-- packages/shared/src/webrtcFraming.test.ts | 153 +++++++++------ packages/shared/src/webrtcFraming.ts | 176 ++++++++++-------- 26 files changed, 888 insertions(+), 415 deletions(-) diff --git a/.env.example b/.env.example index fc67dcef9..896e2fcfa 100644 --- a/.env.example +++ b/.env.example @@ -21,6 +21,14 @@ T3CODE_CLERK_CLI_OAUTH_CLIENT_ID=hzxSgY2cH10sDU2r # automatically. T3CODE_RELAY_URL=https://relay.t3.codes +# Optional WebRTC RPC fast-path ICE configuration. STUN defaults to Cloudflare; +# TURN is disabled unless URLs are configured. Keep real TURN credentials out +# of committed env files. +# T3CODE_WEBRTC_STUN_URLS=stun:stun.cloudflare.com:3478 +# T3CODE_WEBRTC_TURN_URLS=turn:turn.example.com:3478,turns:turn.example.com:5349 +# T3CODE_WEBRTC_TURN_USERNAME=turn-user +# T3CODE_WEBRTC_TURN_CREDENTIAL=replace-me + # Optional: hosted app origin used by the CLI's out-of-band OAuth flow. # Defaults to https://app.t3.codes; override to test against a staging deployment. # T3CODE_HOSTED_APP_URL=https://nightly.app.t3.codes diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index 72245a377..4981bf19c 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -22,7 +22,7 @@ import { Wakeups, } from "@t3tools/client-runtime/connection"; import { managedRelayAccountChanges, managedRelaySessionAtom } from "@t3tools/client-runtime/relay"; -import { AuthStandardClientScopes } from "@t3tools/contracts"; +import { AuthStandardClientScopes, type WebRtcIceServer } from "@t3tools/contracts"; import type { WebRtcDataChannelPort } from "@t3tools/shared/webrtcDataChannel"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; @@ -117,16 +117,25 @@ function mobileDataChannelPort(channel: MobileDataChannel): WebRtcDataChannelPor }; } -function mobileSessionDescription(description: RTCSessionDescription): WebRtcSessionDescription { +function mobileSessionDescription( + description: RTCSessionDescription, +): WebRtcSessionDescription | null { if (description.type !== "offer" && description.type !== "answer") { - throw new Error("Mobile WebRTC returned an invalid session description."); + return null; } return { type: description.type, sdp: description.sdp }; } -function createMobilePeerConnection(stunUrls: ReadonlyArray): PlatformWebRtcPeerConnection { +function createMobilePeerConnection( + iceServers: ReadonlyArray, +): PlatformWebRtcPeerConnection { const peer = new RTCPeerConnection({ - iceServers: stunUrls.map((urls) => ({ urls })), + iceServers: iceServers.map((server) => ({ + urls: [...server.urls], + ...(server.username !== undefined && server.credential !== undefined + ? { username: server.username, credential: server.credential } + : {}), + })), }); return { createDataChannel: (label) => diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index ff5a85b2f..35880832e 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -27,11 +27,15 @@ const deriveExplicitServerPaths = (baseDir: string, devUrl: URL | undefined) => const encodeDesktopBootstrap = Schema.encodeEffect(Schema.fromJsonString(DesktopBackendBootstrap)); -it("parses and bounds the WebRTC UDP port range", () => { - expect(parseWebRtcUdpPortRange("60000-61000")).toEqual([60_000, 61_000]); - expect(() => parseWebRtcUdpPortRange("61000-60000")).toThrow(); - expect(() => parseWebRtcUdpPortRange("80-81")).toThrow(); -}); +it.effect("parses and bounds the WebRTC UDP port range", () => + Effect.gen(function* () { + expect(yield* parseWebRtcUdpPortRange("60000-61000")).toEqual([60_000, 61_000]); + expect((yield* Effect.flip(parseWebRtcUdpPortRange("61000-60000"))).reason).toBe( + "invalid-range", + ); + expect((yield* Effect.flip(parseWebRtcUdpPortRange("80-81"))).reason).toBe("invalid-range"); + }), +); const makeDesktopBootstrap = ( overrides: Partial = {}, @@ -65,7 +69,7 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { desktopTelemetryControlFd: undefined, resourceMonitorPath: undefined, webRtcFastPathEnabled: true, - webRtcStunUrls: ["stun:stun.cloudflare.com:3478"], + webRtcIceServers: [{ urls: ["stun:stun.cloudflare.com:3478"] }], webRtcUdpPortRange: [60_000, 61_000], } as const; const openBootstrapFd = Effect.fn(function* (payload: DesktopBackendBootstrapValue) { @@ -124,6 +128,10 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { T3CODE_WEBRTC_FAST_PATH: "0", T3CODE_WEBRTC_STUN_URLS: "stun:stun1.example.test:3478, stuns:stun2.example.test:5349", + T3CODE_WEBRTC_TURN_URLS: + "turn:turn1.example.test:3478?transport=udp, turns:turn2.example.test:5349?transport=tcp", + T3CODE_WEBRTC_TURN_USERNAME: "turn-user", + T3CODE_WEBRTC_TURN_CREDENTIAL: "turn-credential", T3CODE_WEBRTC_UDP_PORT_RANGE: "62000-62100", }, }), @@ -155,7 +163,19 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { tailscaleServeEnabled: false, tailscaleServePort: 443, webRtcFastPathEnabled: false, - webRtcStunUrls: ["stun:stun1.example.test:3478", "stuns:stun2.example.test:5349"], + webRtcIceServers: [ + { + urls: ["stun:stun1.example.test:3478", "stuns:stun2.example.test:5349"], + }, + { + urls: [ + "turn:turn1.example.test:3478?transport=udp", + "turns:turn2.example.test:5349?transport=tcp", + ], + username: "turn-user", + credential: "turn-credential", + }, + ], webRtcUdpPortRange: [62_000, 62_100], }); assert.equal(resolved.stateDir, join(baseDir, "userdata")); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index 9d114a02f..66f8255e2 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -1,6 +1,6 @@ import * as NetService from "@t3tools/shared/Net"; import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings"; -import { DesktopBackendBootstrap, PortSchema } from "@t3tools/contracts"; +import { DesktopBackendBootstrap, PortSchema, type WebRtcIceServer } from "@t3tools/contracts"; import * as Config from "effect/Config"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -8,13 +8,18 @@ import * as FileSystem from "effect/FileSystem"; import * as LogLevel from "effect/LogLevel"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; +import * as Redacted from "effect/Redacted"; import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; import * as SchemaTransformation from "effect/SchemaTransformation"; import { Argument, Flag } from "effect/unstable/cli"; import { normalizeBasePath } from "@t3tools/shared/basePath"; -import { DEFAULT_WEBRTC_STUN_URLS, validateStunUrls } from "@t3tools/shared/webrtcCandidatePolicy"; +import { + DEFAULT_WEBRTC_STUN_URLS, + validateStunUrls, + validateTurnUrls, +} from "@t3tools/shared/webrtcCandidatePolicy"; import { readBootstrapEnvelope } from "../bootstrap.ts"; import * as ServerConfig from "../config.ts"; import { expandHomePath, resolveBaseDir } from "../os-jank.ts"; @@ -151,43 +156,106 @@ const EnvServerConfig = Config.all({ Config.map(Option.getOrUndefined), ), webRtcFastPathEnabled: Config.boolean("T3CODE_WEBRTC_FAST_PATH").pipe(Config.withDefault(true)), - webRtcStunUrls: Config.string("T3CODE_WEBRTC_STUN_URLS").pipe( - Config.withDefault(DEFAULT_WEBRTC_STUN_URLS.join(",")), - Config.map((value) => - value - .split(",") - .map((entry) => entry.trim()) - .filter((entry) => entry.length > 0), + webRtcIceServers: Config.all({ + stunUrls: Config.string("T3CODE_WEBRTC_STUN_URLS").pipe( + Config.withDefault(DEFAULT_WEBRTC_STUN_URLS.join(",")), + Config.map((value) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ), + ), + turnUrls: Config.string("T3CODE_WEBRTC_TURN_URLS").pipe( + Config.withDefault(""), + Config.map((value) => + value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0), + ), + ), + turnUsername: Config.string("T3CODE_WEBRTC_TURN_USERNAME").pipe( + Config.option, + Config.map(Option.getOrUndefined), ), - Config.mapOrFail((urls) => - Effect.try({ - try: () => validateStunUrls(urls), - catch: () => - new Config.ConfigError( - new Schema.SchemaError( - new SchemaIssue.InvalidValue({ - message: "T3CODE_WEBRTC_STUN_URLS accepts only comma-separated stun: URLs.", - }), + turnCredential: Config.redacted("T3CODE_WEBRTC_TURN_CREDENTIAL").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), + }).pipe( + Config.mapOrFail((settings) => + Effect.gen(function* () { + const stunUrls = yield* validateStunUrls(settings.stunUrls); + const turnUrls = yield* validateTurnUrls(settings.turnUrls); + const trimmedTurnUsername = settings.turnUsername?.trim(); + const turnUsername = + trimmedTurnUsername === undefined || trimmedTurnUsername.length === 0 + ? undefined + : trimmedTurnUsername; + const trimmedTurnCredential = + settings.turnCredential === undefined + ? undefined + : Redacted.value(settings.turnCredential).trim(); + const turnCredentialValue = + trimmedTurnCredential === undefined || trimmedTurnCredential.length === 0 + ? undefined + : trimmedTurnCredential; + if ((turnUsername === undefined) !== (turnCredentialValue === undefined)) { + return yield* Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: + "T3CODE_WEBRTC_TURN_USERNAME and T3CODE_WEBRTC_TURN_CREDENTIAL must be configured together.", + }), + ), + ), + ); + } + + const iceServers: Array = []; + if (stunUrls.length > 0) { + iceServers.push({ urls: stunUrls }); + } + if (turnUrls.length > 0) { + iceServers.push( + turnUsername !== undefined && turnCredentialValue !== undefined + ? { urls: turnUrls, username: turnUsername, credential: turnCredentialValue } + : { urls: turnUrls }, + ); + } + return iceServers; + }).pipe( + Effect.catchTag("WebRtcCandidatePolicyError", (error) => + Effect.fail( + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: error.message, + }), + ), ), ), - }), + ), + ), ), ), webRtcUdpPortRange: Config.string("T3CODE_WEBRTC_UDP_PORT_RANGE").pipe( Config.withDefault(DEFAULT_WEBRTC_UDP_PORT_RANGE.join("-")), Config.mapOrFail((value) => - Effect.try({ - try: () => parseWebRtcUdpPortRange(value), - catch: () => - new Config.ConfigError( - new Schema.SchemaError( - new SchemaIssue.InvalidValue({ - message: - "T3CODE_WEBRTC_UDP_PORT_RANGE must be two ports from 1024 to 65535 in min-max order.", - }), + parseWebRtcUdpPortRange(value).pipe( + Effect.mapError( + (error) => + new Config.ConfigError( + new Schema.SchemaError( + new SchemaIssue.InvalidValue({ + message: error.message, + }), + ), ), - ), - }), + ), + ), ), ), }); @@ -447,7 +515,7 @@ export const resolveServerConfig = ( tailscaleServeEnabled, tailscaleServePort, webRtcFastPathEnabled: env.webRtcFastPathEnabled, - webRtcStunUrls: env.webRtcStunUrls, + webRtcIceServers: env.webRtcIceServers, webRtcUdpPortRange: env.webRtcUdpPortRange, }; diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 492f5ef5f..c83560702 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -14,6 +14,7 @@ import * as LogLevel from "effect/LogLevel"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import type { WebRtcIceServer } from "@t3tools/contracts"; import { ROOT_BASE_PATH, type NormalizedBasePath } from "@t3tools/shared/basePath"; import type { WebRtcUdpPortRange } from "./webrtc/config.ts"; @@ -89,7 +90,7 @@ export class ServerConfig extends Context.Service< readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; readonly webRtcFastPathEnabled?: boolean; - readonly webRtcStunUrls?: ReadonlyArray; + readonly webRtcIceServers?: ReadonlyArray; readonly webRtcUdpPortRange?: WebRtcUdpPortRange; } >()("t3/config/ServerConfig") { diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d1eb99b2d..a9b950bc6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4379,7 +4379,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* buildAppUnderTest({ config: { webRtcFastPathEnabled: true, - webRtcStunUrls: [], + webRtcIceServers: [], }, }); @@ -4390,7 +4390,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { yield* Effect.addFinalizer(() => Effect.tryPromise({ try: () => clientPeer.close(), - catch: () => new ServerWebRtcPeerError("connection"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "connection", cause }), }).pipe(Effect.ignore), ); const rtcClosed = yield* Deferred.make(); @@ -4412,8 +4412,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.deepEqual(controlConfig.environment.capabilities.webRtcRpcFastPath, { version: 1, signaling: "same-websocket-rpc", - turn: false, - stunUrls: [], + iceServers: [], }); const offer = yield* Effect.tryPromise({ @@ -4422,7 +4421,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { await clientPeer.setLocalDescription(pendingOffer); return clientPeer.localDescription; }, - catch: () => new ServerWebRtcPeerError("offer"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "offer", cause }), }); if (offer === null || offer.type !== "offer") { return yield* Effect.die(new Error("WebRTC client did not produce an offer.")); @@ -4434,7 +4433,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }); yield* Effect.tryPromise({ try: () => clientPeer.setRemoteDescription({ type: "answer", sdp: answer.answerSdp }), - catch: () => new ServerWebRtcPeerError("offer"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "offer", cause }), }); yield* rtcConnection.awaitOpen.pipe(Effect.timeout("10 seconds")); yield* rtcConnection.sendBinding( diff --git a/apps/server/src/webrtc/WebRtcFastPathController.test.ts b/apps/server/src/webrtc/WebRtcFastPathController.test.ts index 65ff2f327..16a67957d 100644 --- a/apps/server/src/webrtc/WebRtcFastPathController.test.ts +++ b/apps/server/src/webrtc/WebRtcFastPathController.test.ts @@ -1,4 +1,4 @@ -import { WebRtcBindingFrame } from "@t3tools/contracts"; +import { WebRtcBindingFrame, type WebRtcIceServer } from "@t3tools/contracts"; import { makeWebRtcDataChannelConnection, type WebRtcDataChannelPort, @@ -134,7 +134,7 @@ const makeHarness = Effect.fn("WebRtcFastPathController.test.makeHarness")(funct .pipe(Effect.forkScoped); const controller = yield* makeWebRtcFastPathController({ enabled: true, - stunUrls: [], + iceServers: [], runtime: Option.some(runtime), socketServer, attemptTtlMs, @@ -159,7 +159,7 @@ describe("WebRtcFastPathController", () => { Effect.gen(function* () { const controller = yield* makeWebRtcFastPathController({ enabled: true, - stunUrls: [], + iceServers: [], runtime: Option.none(), socketServer: yield* makeSingleSocketServer(), }); @@ -176,6 +176,51 @@ describe("WebRtcFastPathController", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("advertises and applies configured STUN and TURN servers", () => + Effect.gen(function* () { + const configuredIceServers: ReadonlyArray = [ + { urls: ["stun:stun.example.test:3478"] }, + { + urls: ["turns:turn.example.test:5349?transport=tcp"], + username: "turn-user", + credential: "turn-credential", + }, + ]; + const receivedIceServers = yield* Deferred.make>(); + const peer: ServerWebRtcPeer = { + acceptOffer: () => Effect.succeed("v=0\r\n"), + takeDataChannel: Effect.never, + closed: Effect.never, + diagnosticState: Effect.succeed({ + connectionState: "new", + gatheringState: "new", + iceState: "new", + }), + selectedIcePairType: Effect.succeed(null), + bytesSent: Effect.succeed(0), + bytesReceived: Effect.succeed(0), + close: Effect.void, + }; + const controller = yield* makeWebRtcFastPathController({ + enabled: true, + iceServers: configuredIceServers, + runtime: Option.some({ + createPeer: (_attemptId, iceServers) => + Deferred.succeed(receivedIceServers, iceServers).pipe(Effect.as(peer)), + }), + socketServer: yield* makeSingleSocketServer(), + }); + + expect(controller.capability?.iceServers).toEqual(configuredIceServers); + yield* controller.negotiate({ + version: 1, + attemptId: "attempt-with-turn", + offerSdp: "v=0\r\n", + }); + expect(yield* Deferred.await(receivedIceServers)).toEqual(configuredIceServers); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("accepts only the binding token issued for the control WebSocket", () => Effect.gen(function* () { const harness = yield* makeHarness(); @@ -252,8 +297,12 @@ describe("WebRtcFastPathController", () => { it.effect("closes a partially initialized peer when offer negotiation fails", () => Effect.gen(function* () { const closeCount = yield* Ref.make(0); + const peerError = new ServerWebRtcPeerError({ + stage: "offer", + cause: new Error("Test offer failure."), + }); const peer: ServerWebRtcPeer = { - acceptOffer: () => Effect.fail(new ServerWebRtcPeerError("offer")), + acceptOffer: () => Effect.fail(peerError), takeDataChannel: Effect.never, closed: Effect.never, diagnosticState: Effect.succeed({ @@ -268,7 +317,7 @@ describe("WebRtcFastPathController", () => { }; const controller = yield* makeWebRtcFastPathController({ enabled: true, - stunUrls: [], + iceServers: [], runtime: Option.some({ createPeer: () => Effect.succeed(peer) }), socketServer: yield* makeSingleSocketServer(), }); @@ -282,6 +331,10 @@ describe("WebRtcFastPathController", () => { ); expect(error._tag).toBe("WebRtcFastPathNegotiationError"); + if (error._tag !== "WebRtcFastPathNegotiationError") { + return yield* Effect.die(new Error("Expected WebRtcFastPathNegotiationError.")); + } + expect(error.cause).toBe(peerError); expect(yield* Ref.get(closeCount)).toBe(1); }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); @@ -307,7 +360,7 @@ describe("WebRtcFastPathController", () => { }; const controller = yield* makeWebRtcFastPathController({ enabled: true, - stunUrls: [], + iceServers: [], runtime: Option.some({ createPeer: () => Effect.succeed(peer) }), socketServer: yield* makeSingleSocketServer(), }); diff --git a/apps/server/src/webrtc/WebRtcFastPathController.ts b/apps/server/src/webrtc/WebRtcFastPathController.ts index 218a75d8d..450d5c5e1 100644 --- a/apps/server/src/webrtc/WebRtcFastPathController.ts +++ b/apps/server/src/webrtc/WebRtcFastPathController.ts @@ -8,13 +8,11 @@ import { WebRtcFastPathUnsupportedError, type WebRtcNegotiateInput, type WebRtcNegotiateResult, + type WebRtcIceServer, type WebRtcRpcFastPathCapability, type WebRtcSignalingError, } from "@t3tools/contracts"; -import { - validateSessionDescription, - validateStunUrls, -} from "@t3tools/shared/webrtcCandidatePolicy"; +import { validateSessionDescription } from "@t3tools/shared/webrtcCandidatePolicy"; import { makeWebRtcDataChannelConnection } from "@t3tools/shared/webrtcDataChannel"; import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; @@ -57,7 +55,7 @@ export interface WebRtcFastPathController { export interface WebRtcFastPathControllerOptions { readonly enabled: boolean; - readonly stunUrls: ReadonlyArray; + readonly iceServers: ReadonlyArray; readonly runtime: Option.Option; readonly socketServer: SingleSocketServer; readonly udpPortRange?: WebRtcUdpPortRange; @@ -78,17 +76,17 @@ export const makeWebRtcFastPathController = Effect.fn("WebRtcFastPathController. const attemptTimes = yield* Ref.make>([]); const attemptTtlMs = options.attemptTtlMs ?? DEFAULT_ATTEMPT_TTL_MS; const runtime = Option.getOrNull(options.runtime); - const stunUrls = yield* Effect.try({ - try: () => validateStunUrls(options.stunUrls), - catch: () => null, - }); const capability: WebRtcRpcFastPathCapability | null = - options.enabled && runtime !== null && stunUrls !== null + options.enabled && runtime !== null ? { version: 1, signaling: "same-websocket-rpc", - turn: false, - stunUrls: [...stunUrls], + iceServers: options.iceServers.map((server) => ({ + urls: [...server.urls], + ...(server.username === undefined + ? {} + : { username: server.username, credential: server.credential }), + })), } : null; @@ -117,9 +115,10 @@ export const makeWebRtcFastPathController = Effect.fn("WebRtcFastPathController. const bindingBytes = yield* connection.awaitBinding; const binding = yield* decodeBindingFrame(new TextDecoder().decode(bindingBytes)).pipe( Effect.mapError( - () => + (cause) => new WebRtcFastPathInvalidAttemptError({ message: "WebRTC binding frame is invalid.", + cause, }), ), ); @@ -175,15 +174,20 @@ export const makeWebRtcFastPathController = Effect.fn("WebRtcFastPathController. if (!options.enabled) { return yield* new WebRtcFastPathDisabledError({ message: "WebRTC fast path is disabled." }); } - if (runtime === null || capability === null || stunUrls === null) { + if (runtime === null || capability === null) { return yield* new WebRtcFastPathUnsupportedError({ message: "WebRTC fast path is unavailable on this server.", }); } - yield* Effect.try({ - try: () => validateSessionDescription(input.offerSdp), - catch: () => new WebRtcFastPathInvalidSdpError({ message: "WebRTC offer SDP is invalid." }), - }); + yield* validateSessionDescription(input.offerSdp).pipe( + Effect.mapError( + (cause) => + new WebRtcFastPathInvalidSdpError({ + message: "WebRTC offer SDP is invalid.", + cause, + }), + ), + ); const nowMs = yield* Clock.currentTimeMillis; const recentAttempts = (yield* Ref.get(attemptTimes)).filter( (attemptedAtMs) => nowMs - attemptedAtMs < RATE_LIMIT_WINDOW_MS, @@ -197,37 +201,46 @@ export const makeWebRtcFastPathController = Effect.fn("WebRtcFastPathController. const previous = yield* Ref.getAndSet(active, null); yield* closeAttempt(previous); const peer = yield* runtime - .createPeer(input.attemptId, stunUrls, options.udpPortRange ?? DEFAULT_WEBRTC_UDP_PORT_RANGE) + .createPeer( + input.attemptId, + options.iceServers, + options.udpPortRange ?? DEFAULT_WEBRTC_UDP_PORT_RANGE, + ) .pipe( Effect.mapError( - () => + (cause) => new WebRtcFastPathNegotiationError({ message: "WebRTC peer initialization failed.", + cause, }), ), ); return yield* Effect.gen(function* () { - const answerSdp = yield* peer - .acceptOffer(input.offerSdp) - .pipe( - Effect.mapError( - () => - new WebRtcFastPathNegotiationError({ message: "WebRTC offer negotiation failed." }), - ), - ); - yield* Effect.try({ - try: () => validateSessionDescription(answerSdp), - catch: () => - new WebRtcFastPathNegotiationError({ - message: "WebRTC generated an invalid answer.", - }), - }); + const answerSdp = yield* peer.acceptOffer(input.offerSdp).pipe( + Effect.mapError( + (cause) => + new WebRtcFastPathNegotiationError({ + message: "WebRTC offer negotiation failed.", + cause, + }), + ), + ); + yield* validateSessionDescription(answerSdp).pipe( + Effect.mapError( + (cause) => + new WebRtcFastPathNegotiationError({ + message: "WebRTC generated an invalid answer.", + cause, + }), + ), + ); const bindingToken = yield* crypto.randomBytes(32).pipe( Effect.map(Encoding.encodeBase64Url), Effect.mapError( - () => + (cause) => new WebRtcFastPathNegotiationError({ message: "WebRTC binding token generation failed.", + cause, }), ), ); diff --git a/apps/server/src/webrtc/WebRtcPeer.test.ts b/apps/server/src/webrtc/WebRtcPeer.test.ts index b9588bc76..2f18387e7 100644 --- a/apps/server/src/webrtc/WebRtcPeer.test.ts +++ b/apps/server/src/webrtc/WebRtcPeer.test.ts @@ -76,7 +76,7 @@ it.effect("binds and fragments RPC messages over a real DataChannel", () => ); const controller = yield* makeWebRtcFastPathController({ enabled: true, - stunUrls: [], + iceServers: [], runtime: Option.some(runtime), socketServer, }); @@ -88,7 +88,7 @@ it.effect("binds and fragments RPC messages over a real DataChannel", () => yield* Effect.addFinalizer(() => Effect.tryPromise({ try: () => clientPeer.close(), - catch: () => new ServerWebRtcPeerError("connection"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "connection", cause }), }).pipe(Effect.ignore), ); const clientChannel = clientPeer.createDataChannel("t3-rpc-v1"); @@ -101,7 +101,7 @@ it.effect("binds and fragments RPC messages over a real DataChannel", () => await clientPeer.setLocalDescription(pendingOffer); return clientPeer.localDescription; }, - catch: () => new ServerWebRtcPeerError("offer"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "offer", cause }), }); if (offer === null || offer.type !== "offer") { return yield* Effect.die(new Error("WebRTC did not create an offer.")); @@ -113,7 +113,7 @@ it.effect("binds and fragments RPC messages over a real DataChannel", () => }); yield* Effect.tryPromise({ try: () => clientPeer.setRemoteDescription({ type: "answer", sdp: answer.answerSdp }), - catch: () => new ServerWebRtcPeerError("offer"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "offer", cause }), }); yield* withPeerTimeout(clientConnection.awaitOpen, "DataChannel open"); yield* clientConnection.sendBinding( @@ -155,7 +155,7 @@ it.effect("buffers a binding frame sent before the server socket attaches", () = Effect.gen(function* () { const stateChanged = new Event<["connecting" | "open" | "closing" | "closed"]>(); const messages = new Event<[string | Buffer]>(); - const readyState: "open" = "open"; + const readyState = "open" as const; const channel = { label: "t3-rpc-v1", ordered: true, diff --git a/apps/server/src/webrtc/WebRtcPeer.ts b/apps/server/src/webrtc/WebRtcPeer.ts index 22feae24f..700670f77 100644 --- a/apps/server/src/webrtc/WebRtcPeer.ts +++ b/apps/server/src/webrtc/WebRtcPeer.ts @@ -5,7 +5,9 @@ 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 Schema from "effect/Schema"; +import type { WebRtcIceServer } from "@t3tools/contracts"; import type { WebRtcDataChannelPort } from "@t3tools/shared/webrtcDataChannel"; import { isTerminalWebRtcPeerConnectionState } from "@t3tools/shared/webrtcPeerState"; @@ -29,20 +31,22 @@ export interface ServerWebRtcPeer { readonly close: Effect.Effect; } -export class ServerWebRtcPeerError extends Error { - readonly stage: "create" | "offer" | "connection"; - - constructor(stage: "create" | "offer" | "connection") { - super(`Server WebRTC peer failed during ${stage}.`); - this.name = "ServerWebRtcPeerError"; - this.stage = stage; +export class ServerWebRtcPeerError extends Schema.TaggedErrorClass()( + "ServerWebRtcPeerError", + { + stage: Schema.Literals(["create", "offer", "connection"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Server WebRTC peer failed during ${this.stage}.`; } } export interface ServerWebRtcRuntime { readonly createPeer: ( attemptId: string, - stunUrls: ReadonlyArray, + iceServers: ReadonlyArray, udpPortRange: WebRtcUdpPortRange, ) => Effect.Effect; } @@ -121,7 +125,8 @@ export function weriftDataChannelPort(channel: WeriftDataChannelAdapter): WebRtc }, onMessage: (listener) => { if (messageListener !== null) { - throw new Error("WebRTC DataChannel already has a message listener."); + channel.close(); + return () => undefined; } messageListener = listener; for (const message of earlyMessages.splice(0)) { @@ -161,7 +166,15 @@ function makeWeriftPeer(connection: RTCPeerConnection): Effect.Effect = []; const stateSubscription = connection.connectionStateChange.subscribe((state) => { if (isTerminalWebRtcPeerConnectionState(state)) { - Deferred.doneUnsafe(closed, Effect.fail(new ServerWebRtcPeerError("connection"))); + Deferred.doneUnsafe( + closed, + Effect.fail( + new ServerWebRtcPeerError({ + stage: "connection", + cause: new Error(`WebRTC peer entered ${state} state.`), + }), + ), + ); } }); const dataChannelSubscription = connection.onDataChannel.subscribe((channel) => { @@ -176,18 +189,21 @@ function makeWeriftPeer(connection: RTCPeerConnection): Effect.Effect new ServerWebRtcPeerError("offer"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "offer", cause }), }).pipe(Effect.raceFirst(Deferred.await(closed))); const answer = connection.localDescription; if (answer === null || answer.type !== "answer") { - return yield* Effect.fail(new ServerWebRtcPeerError("offer")); + return yield* new ServerWebRtcPeerError({ + stage: "offer", + cause: new Error("WebRTC peer did not produce a complete answer."), + }); } return answer.sdp; }); const close = Effect.tryPromise({ try: () => connection.close(), - catch: () => new ServerWebRtcPeerError("connection"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "connection", cause }), }).pipe( Effect.ensuring( Effect.sync(() => { @@ -201,7 +217,7 @@ function makeWeriftPeer(connection: RTCPeerConnection): Effect.Effect new ServerWebRtcPeerError("connection")), + Effect.mapError((cause) => new ServerWebRtcPeerError({ stage: "connection", cause })), Effect.raceFirst(Deferred.await(closed)), ), closed: Deferred.await(closed), @@ -233,24 +249,29 @@ function makeWeriftPeer(connection: RTCPeerConnection): Effect.Effect> = Effect.tryPromise({ try: () => import("werift"), - catch: () => new ServerWebRtcPeerError("create"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "create", cause }), }).pipe( Effect.map( (werift) => ({ createPeer: Effect.fn("ServerWebRtcRuntime.createPeer")(function* ( _attemptId: string, - stunUrls: ReadonlyArray, + iceServers: ReadonlyArray, udpPortRange: WebRtcUdpPortRange, ) { const connection = yield* Effect.try({ try: () => new werift.RTCPeerConnection({ - iceServers: stunUrls.map((urls) => ({ urls })), + iceServers: iceServers.map((server) => ({ + urls: [...server.urls], + ...(server.username === undefined + ? {} + : { username: server.username, credential: server.credential }), + })), icePortRange: [...udpPortRange], maxMessageSize: 16 * 1024, }), - catch: () => new ServerWebRtcPeerError("create"), + catch: (cause) => new ServerWebRtcPeerError({ stage: "create", cause }), }); return yield* makeWeriftPeer(connection); }), diff --git a/apps/server/src/webrtc/config.ts b/apps/server/src/webrtc/config.ts index cad77ea18..c69d4e471 100644 --- a/apps/server/src/webrtc/config.ts +++ b/apps/server/src/webrtc/config.ts @@ -1,11 +1,27 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + export type WebRtcUdpPortRange = readonly [minimum: number, maximum: number]; export const DEFAULT_WEBRTC_UDP_PORT_RANGE: WebRtcUdpPortRange = [60_000, 61_000]; -export function parseWebRtcUdpPortRange(value: string): WebRtcUdpPortRange { +export class WebRtcUdpPortRangeConfigError extends Schema.TaggedErrorClass()( + "WebRtcUdpPortRangeConfigError", + { reason: Schema.Literals(["invalid-syntax", "invalid-range"]) }, +) { + override get message(): string { + return this.reason === "invalid-syntax" + ? "WebRTC UDP port range must use min-max syntax." + : "WebRTC UDP port range is invalid."; + } +} + +export const parseWebRtcUdpPortRange = Effect.fn("WebRtcConfig.parseUdpPortRange")(function* ( + value: string, +) { const match = /^(\d+)-(\d+)$/.exec(value.trim()); if (match === null) { - throw new Error("WebRTC UDP port range must use min-max syntax."); + return yield* new WebRtcUdpPortRangeConfigError({ reason: "invalid-syntax" }); } const minimum = Number(match[1]); const maximum = Number(match[2]); @@ -16,7 +32,7 @@ export function parseWebRtcUdpPortRange(value: string): WebRtcUdpPortRange { maximum > 65_535 || minimum >= maximum ) { - throw new Error("WebRTC UDP port range is invalid."); + return yield* new WebRtcUdpPortRangeConfigError({ reason: "invalid-range" }); } - return [minimum, maximum]; -} + return [minimum, maximum] satisfies WebRtcUdpPortRange; +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3716f35c1..7a643dd4c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -2304,7 +2304,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( config.webRtcFastPathEnabled === true ? yield* loadServerWebRtcRuntime : Option.none(); const webRtc = yield* makeWebRtcFastPathController({ enabled: config.webRtcFastPathEnabled === true, - stunUrls: config.webRtcStunUrls ?? [], + iceServers: config.webRtcIceServers ?? [], ...(config.webRtcUdpPortRange === undefined ? {} : { udpPortRange: config.webRtcUdpPortRange }), diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 6ba696767..e9baa4d36 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -39,6 +39,7 @@ import { type DesktopEnvironmentBootstrap, type DesktopSshEnvironmentTarget, PRIMARY_LOCAL_ENVIRONMENT_ID, + type WebRtcIceServer, } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; @@ -110,19 +111,26 @@ function webDataChannelPort(channel: RTCDataChannel): WebRtcDataChannelPort { function webSessionDescription( description: RTCSessionDescription | RTCSessionDescriptionInit, -): WebRtcSessionDescription { +): WebRtcSessionDescription | null { if ( (description.type !== "offer" && description.type !== "answer") || description.sdp === undefined ) { - throw new Error("Browser WebRTC returned an invalid session description."); + return null; } return { type: description.type, sdp: description.sdp }; } -function createWebPeerConnection(stunUrls: ReadonlyArray): PlatformWebRtcPeerConnection { +function createWebPeerConnection( + iceServers: ReadonlyArray, +): PlatformWebRtcPeerConnection { const peer = new RTCPeerConnection({ - iceServers: stunUrls.map((urls) => ({ urls })), + iceServers: iceServers.map((server) => ({ + urls: [...server.urls], + ...(server.username !== undefined && server.credential !== undefined + ? { username: server.username, credential: server.credential } + : {}), + })), }); return { createDataChannel: (label) => diff --git a/docs/internals/webrtc-rpc-fast-path.md b/docs/internals/webrtc-rpc-fast-path.md index 0d5df2fe9..80ad07048 100644 --- a/docs/internals/webrtc-rpc-fast-path.md +++ b/docs/internals/webrtc-rpc-fast-path.md @@ -19,19 +19,23 @@ a selected DataChannel closes, the normal environment supervisor replaces the whole session. It does not replay requests or move in-flight streams between transports. -## ICE policy and support +## ICE configuration and support The server uses `werift`, a pure TypeScript WebRTC implementation that ships in the existing Node and desktop bundles without a native artifact. Web and desktop renderers use the browser WebRTC implementation. Mobile uses `react-native-webrtc` through its Expo config plugin. -Only direct host, peer-reflexive, and server-reflexive ICE candidates are -accepted. Configuration rejects `turn:` and `turns:` URLs, and both peers reject -SDP containing `typ relay`. There is no TURN service or TURN fallback. Symmetric -NATs, restrictive firewalls, blocked UDP, and some enterprise networks can -therefore prevent the fast path. T3 Code silently keeps using the already-open -WebSocket in those cases. +The default configuration uses one public STUN server and no TURN server. A +server operator can add `turn:` or `turns:` URLs and credentials through the +environment. The authenticated capability response sends the resulting ICE +server list to web, desktop, and mobile clients, and both peers use the same +list. T3 Code does not run a TURN service itself. + +Without configured TURN, symmetric NATs, restrictive firewalls, blocked UDP, +and some enterprise networks can prevent the fast path. A configured TURN +server allows relay candidates for those networks. If ICE still fails, T3 Code +keeps using the already-open WebSocket. The upgrade runs after any connection target has produced an authenticated environment WebSocket. Relay, managed endpoint, manual bearer, SSH-forwarded, @@ -40,15 +44,20 @@ web, desktop, and mobile connections use the same path. ## Configuration - `T3CODE_WEBRTC_FAST_PATH=0` disables the server capability. -- `T3CODE_WEBRTC_STUN_URLS` is a comma-separated STUN-only list. The default is +- `T3CODE_WEBRTC_STUN_URLS` is a comma-separated STUN list. The default is `stun:stun.cloudflare.com:3478`. An empty list permits host candidates only. +- `T3CODE_WEBRTC_TURN_URLS` is a comma-separated TURN list. It is empty by + default. Both `turn:` and `turns:` URLs are accepted. +- `T3CODE_WEBRTC_TURN_USERNAME` and `T3CODE_WEBRTC_TURN_CREDENTIAL` configure + TURN password authentication. Set both or neither. The credential is read as + a redacted server setting and is never written to logs. - `T3CODE_WEBRTC_UDP_PORT_RANGE` sets the server candidate range as `min-max`. The default is `60000-61000`. Host firewalls must allow inbound UDP on this range for direct and server-reflexive candidates to work. -The server advertises the capability only when it is enabled, the STUN list is -valid, and the optional runtime loads. A missing runtime never prevents -WebSocket startup. +The server advertises the capability only when it is enabled and the optional +runtime loads. Invalid ICE configuration fails with a typed configuration error. +A missing WebRTC runtime never prevents WebSocket startup. ## Diagnostics diff --git a/packages/client-runtime/src/platform/capabilities.ts b/packages/client-runtime/src/platform/capabilities.ts index 42b7e4396..4ffb75801 100644 --- a/packages/client-runtime/src/platform/capabilities.ts +++ b/packages/client-runtime/src/platform/capabilities.ts @@ -4,6 +4,7 @@ import { type DesktopSshEnvironmentBootstrap, type DesktopSshEnvironmentTarget, EnvironmentId, + type WebRtcIceServer, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; @@ -91,7 +92,7 @@ export interface WebRtcPeer { export interface WebRtcPeerFactoryService { readonly create: ( - stunUrls: ReadonlyArray, + iceServers: ReadonlyArray, ) => Effect.Effect; } diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 85e8b4346..4a5c9344a 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -156,8 +156,7 @@ const WEBRTC_SERVER_CONFIG: ServerConfigType = { webRtcRpcFastPath: { version: 1, signaling: "same-websocket-rpc", - turn: false, - stunUrls: [], + iceServers: [], }, }, }, diff --git a/packages/client-runtime/src/rpc/webrtc/FastPathNegotiator.ts b/packages/client-runtime/src/rpc/webrtc/FastPathNegotiator.ts index 3a48ae87f..b387b537d 100644 --- a/packages/client-runtime/src/rpc/webrtc/FastPathNegotiator.ts +++ b/packages/client-runtime/src/rpc/webrtc/FastPathNegotiator.ts @@ -6,8 +6,8 @@ import { WS_METHODS, } from "@t3tools/contracts"; import { + validateIceServers, validateSessionDescription, - validateStunUrls, } from "@t3tools/shared/webrtcCandidatePolicy"; import { makeWebRtcDataChannelConnection, @@ -44,7 +44,7 @@ export type WebRtcFastPathFallbackReason = typeof WebRtcFastPathFallbackReason.T export class WebRtcFastPathNegotiationError extends Schema.TaggedErrorClass()( "WebRtcFastPathNegotiationError", - { reason: WebRtcFastPathFallbackReason }, + { reason: WebRtcFastPathFallbackReason, cause: Schema.optionalKey(Schema.Defect()) }, ) { override get message(): string { return `WebRTC fast path negotiation failed: ${this.reason}.`; @@ -53,7 +53,7 @@ export class WebRtcFastPathNegotiationError extends Schema.TaggedErrorClass()( "WebRtcFastPathTransportClosedError", - {}, + { cause: Schema.Defect() }, ) { override get message(): string { return "WebRTC fast path transport closed."; @@ -111,48 +111,63 @@ export const negotiateWebRtcFastPath = Effect.fn("RpcSession.negotiateWebRtcFast const negotiate = Effect.gen(function* () { const startedAtMs = yield* Clock.currentTimeMillis; - const stunUrls = yield* Effect.try({ - try: () => validateStunUrls(options.capability.stunUrls), - catch: () => new WebRtcFastPathNegotiationError({ reason: "invalid-capability" }), - }); + const iceServers = yield* validateIceServers(options.capability.iceServers).pipe( + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "invalid-capability", cause }), + ), + ); const peer = yield* options.peerFactory - .create(stunUrls) + .create(iceServers) .pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "offer-failed" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "offer-failed", cause }), + ), ); const offerSdp = yield* peer.createOffer.pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "offer-failed" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "offer-failed", cause }), + ), + ); + yield* validateSessionDescription(offerSdp).pipe( + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "offer-failed", cause }), + ), ); - yield* Effect.try({ - try: () => validateSessionDescription(offerSdp), - catch: () => new WebRtcFastPathNegotiationError({ reason: "offer-failed" }), - }); signalingStarted = true; const answer = yield* options.controlClient[WS_METHODS.transportWebRtcNegotiate]({ version: 1, attemptId, offerSdp, }).pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "signaling-rejected" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "signaling-rejected", cause }), + ), ); if (answer.attemptId !== attemptId) { return yield* new WebRtcFastPathNegotiationError({ reason: "invalid-answer" }); } - yield* Effect.try({ - try: () => validateSessionDescription(answer.answerSdp), - catch: () => new WebRtcFastPathNegotiationError({ reason: "invalid-answer" }), - }); + yield* validateSessionDescription(answer.answerSdp).pipe( + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "invalid-answer", cause }), + ), + ); yield* peer .acceptAnswer(answer.answerSdp) .pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "invalid-answer" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "invalid-answer", cause }), + ), ); const dataChannelStartedAtMs = yield* Clock.currentTimeMillis; const dataChannel = yield* makeWebRtcDataChannelConnection(peer.dataChannel).pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "datachannel-failed" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "datachannel-failed", cause }), + ), ); yield* dataChannel.awaitOpen.pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "datachannel-failed" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "datachannel-failed", cause }), + ), ); const dataChannelOpenedAtMs = yield* Clock.currentTimeMillis; yield* dataChannel @@ -166,17 +181,25 @@ export const negotiateWebRtcFastPath = Effect.fn("RpcSession.negotiateWebRtcFast ), ) .pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "binding-failed" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "binding-failed", cause }), + ), ); yield* dataChannel.awaitBindingAck.pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "binding-failed" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "binding-failed", cause }), + ), ); const client = yield* makeRtcRpcClient(dataChannel); yield* client[WS_METHODS.serverProbe]({}).pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "rpc-probe-failed" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "rpc-probe-failed", cause }), + ), ); const initialConfig = yield* client[WS_METHODS.serverGetConfig]({}).pipe( - Effect.mapError(() => new WebRtcFastPathNegotiationError({ reason: "rpc-probe-failed" })), + Effect.mapError( + (cause) => new WebRtcFastPathNegotiationError({ reason: "rpc-probe-failed", cause }), + ), ); if (initialConfig.environment.environmentId !== options.environmentId) { return yield* new WebRtcFastPathNegotiationError({ reason: "identity-mismatch" }); @@ -210,7 +233,7 @@ export const negotiateWebRtcFastPath = Effect.fn("RpcSession.negotiateWebRtcFast ), ); const closed = Effect.raceFirst(peer.closed, dataChannel.closed).pipe( - Effect.mapError(() => new WebRtcFastPathTransportClosedError({})), + Effect.mapError((cause) => new WebRtcFastPathTransportClosedError({ cause })), ); const close = Effect.all([peer.close, dataChannel.close], { discard: true, diff --git a/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.test.ts b/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.test.ts index 02f011b7d..a486cfab7 100644 --- a/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.test.ts +++ b/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.test.ts @@ -1,6 +1,13 @@ -import { expect, it } from "vite-plus/test"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; -import { selectedIcePairTypeFromStats } from "./WebRtcPeer.ts"; +import type { WebRtcIceServer } from "@t3tools/contracts"; +import type { WebRtcDataChannelPort } from "@t3tools/shared/webrtcDataChannel"; +import { + makeWebRtcPeerFactory, + type PlatformWebRtcPeerConnection, + selectedIcePairTypeFromStats, +} from "./WebRtcPeer.ts"; it("reports only the selected ICE candidate pair types", () => { const stats = new Map([ @@ -31,3 +38,51 @@ it("does not expose unknown candidate data", () => { expect(selectedIcePairTypeFromStats(stats)).toBeNull(); }); + +it.effect("passes TURN configuration to the platform and preserves offer failures", () => + Effect.gen(function* () { + const offerFailure = new Error("Platform offer failure."); + const dataChannel = { + label: "t3-rpc-v1", + ordered: true, + isOpen: () => false, + bufferedAmount: () => 0, + setBufferedAmountLowThreshold: () => undefined, + send: () => undefined, + close: () => undefined, + onOpen: () => () => undefined, + onMessage: () => () => undefined, + onClose: () => () => undefined, + onError: () => () => undefined, + onBufferedAmountLow: () => () => undefined, + } satisfies WebRtcDataChannelPort; + let receivedIceServers: ReadonlyArray = []; + const factory = makeWebRtcPeerFactory((iceServers) => { + receivedIceServers = iceServers; + return { + createDataChannel: () => dataChannel, + createOffer: () => Promise.reject(offerFailure), + setLocalDescription: () => Promise.resolve(), + localDescription: () => null, + setRemoteDescription: () => Promise.resolve(), + iceGatheringState: () => "new", + onIceGatheringStateChange: () => () => undefined, + onConnectionStateChange: () => () => undefined, + selectedIcePairType: () => Promise.resolve(null), + close: () => undefined, + } satisfies PlatformWebRtcPeerConnection; + }); + const configuredIceServers = [ + { + urls: ["turns:turn.example.test:5349"], + username: "turn-user", + credential: "turn-credential", + }, + ]; + + const peer = yield* factory.create(configuredIceServers); + expect(receivedIceServers).toEqual(configuredIceServers); + const error = yield* Effect.flip(peer.createOffer); + expect(error.cause).toBe(offerFailure); + }).pipe(Effect.scoped), +); diff --git a/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.ts b/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.ts index cec4d1a12..58ba38818 100644 --- a/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.ts +++ b/packages/client-runtime/src/rpc/webrtc/WebRtcPeer.ts @@ -1,6 +1,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; +import type { WebRtcIceServer } from "@t3tools/contracts"; import { isTerminalWebRtcPeerConnectionState, type WebRtcPeerConnectionState, @@ -21,7 +22,7 @@ export interface WebRtcSessionDescription { export interface PlatformWebRtcPeerConnection { readonly createDataChannel: (label: string) => WebRtcDataChannelPort; - readonly createOffer: () => Promise; + readonly createOffer: () => Promise; readonly setLocalDescription: (description: WebRtcSessionDescription) => Promise; readonly localDescription: () => WebRtcSessionDescription | null; readonly setRemoteDescription: (description: WebRtcSessionDescription) => Promise; @@ -91,12 +92,16 @@ export function selectedIcePairTypeFromStats(stats: WebRtcStatsReportLike): stri } export function makeWebRtcPeerFactory( - createPeerConnection: (stunUrls: ReadonlyArray) => PlatformWebRtcPeerConnection, + createPeerConnection: ( + iceServers: ReadonlyArray, + ) => PlatformWebRtcPeerConnection, ): WebRtcPeerFactoryService { return { - create: Effect.fn("WebRtcPeerFactory.create")(function* (stunUrls: ReadonlyArray) { + create: Effect.fn("WebRtcPeerFactory.create")(function* ( + iceServers: ReadonlyArray, + ) { const peer = yield* Effect.try({ - try: () => createPeerConnection(stunUrls), + try: () => createPeerConnection(iceServers), catch: (cause) => new WebRtcPeerError({ stage: "create", cause }), }); const gathered = yield* Deferred.make(); @@ -136,6 +141,12 @@ export function makeWebRtcPeerFactory( 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 a valid offer."), + }); + } yield* Effect.tryPromise({ try: () => peer.setLocalDescription(offer), catch: (cause) => new WebRtcPeerError({ stage: "offer", cause }), diff --git a/packages/contracts/src/webrtc.ts b/packages/contracts/src/webrtc.ts index cea17802c..58209d20a 100644 --- a/packages/contracts/src/webrtc.ts +++ b/packages/contracts/src/webrtc.ts @@ -2,11 +2,17 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; +export const WebRtcIceServer = Schema.Struct({ + urls: Schema.Array(TrimmedNonEmptyString), + username: Schema.optionalKey(TrimmedNonEmptyString), + credential: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type WebRtcIceServer = typeof WebRtcIceServer.Type; + export const WebRtcRpcFastPathCapability = Schema.Struct({ version: Schema.Literal(1), signaling: Schema.Literal("same-websocket-rpc"), - turn: Schema.Literal(false), - stunUrls: Schema.Array(TrimmedNonEmptyString), + iceServers: Schema.Array(WebRtcIceServer), }); export type WebRtcRpcFastPathCapability = typeof WebRtcRpcFastPathCapability.Type; @@ -58,17 +64,17 @@ export class WebRtcFastPathBusyError extends Schema.TaggedErrorClass()( "WebRtcFastPathInvalidAttemptError", - { message: TrimmedNonEmptyString }, + { message: TrimmedNonEmptyString, cause: Schema.optionalKey(Schema.Defect()) }, ) {} export class WebRtcFastPathInvalidSdpError extends Schema.TaggedErrorClass()( "WebRtcFastPathInvalidSdpError", - { message: TrimmedNonEmptyString }, + { message: TrimmedNonEmptyString, cause: Schema.Defect() }, ) {} export class WebRtcFastPathNegotiationError extends Schema.TaggedErrorClass()( "WebRtcFastPathNegotiationError", - { message: TrimmedNonEmptyString }, + { message: TrimmedNonEmptyString, cause: Schema.Defect() }, ) {} export const WebRtcSignalingError = Schema.Union([ diff --git a/packages/shared/src/webrtcCandidatePolicy.test.ts b/packages/shared/src/webrtcCandidatePolicy.test.ts index 0d82d66e7..aaec79a96 100644 --- a/packages/shared/src/webrtcCandidatePolicy.test.ts +++ b/packages/shared/src/webrtcCandidatePolicy.test.ts @@ -1,39 +1,85 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import { - validateIceCandidate, + validateIceServers, validateSessionDescription, validateStunUrls, + validateTurnUrls, WebRtcCandidatePolicyError, } from "./webrtcCandidatePolicy.ts"; -describe("WebRTC no-TURN policy", () => { - it("accepts STUN URLs", () => { - expect( - validateStunUrls(["stun:stun.example.test:3478", "stuns:stun.example.test:5349"]), - ).toEqual(["stun:stun.example.test:3478", "stuns:stun.example.test:5349"]); - }); - - it.each(["turn:turn.example.test:3478", "turns:turn.example.test:5349"])( - "rejects %s URLs", - (url) => { - expect(() => validateStunUrls([url])).toThrowError(WebRtcCandidatePolicyError); - }, +describe("WebRTC ICE policy", () => { + it.effect("accepts STUN URLs", () => + Effect.gen(function* () { + expect( + yield* validateStunUrls(["stun:stun.example.test:3478", "stuns:stun.example.test:5349"]), + ).toEqual(["stun:stun.example.test:3478", "stuns:stun.example.test:5349"]); + }), ); - it("rejects relay candidates", () => { - expect(() => - validateIceCandidate( - "candidate:1 1 UDP 1677734910 203.0.113.1 50000 typ relay raddr 0.0.0.0 rport 0", - ), - ).toThrowError(WebRtcCandidatePolicyError); - }); - - it("rejects SDP containing relay candidates", () => { - expect(() => - validateSessionDescription( - "v=0\r\na=candidate:1 1 UDP 1677734910 203.0.113.1 50000 typ relay\r\n", - ), - ).toThrowError(WebRtcCandidatePolicyError); - }); + it.effect("accepts TURN URLs", () => + Effect.gen(function* () { + expect( + yield* validateTurnUrls([ + "turn:turn.example.test:3478?transport=udp", + "turns:turn.example.test:5349?transport=tcp", + ]), + ).toEqual([ + "turn:turn.example.test:3478?transport=udp", + "turns:turn.example.test:5349?transport=tcp", + ]); + }), + ); + + it.effect("preserves TURN credentials in ICE server configuration", () => + Effect.gen(function* () { + const iceServers = [ + { urls: ["stun:stun.example.test:3478"] }, + { + urls: ["turn:turn.example.test:3478"], + username: "test-user", + credential: "test-credential", + }, + ]; + + expect(yield* validateIceServers(iceServers)).toEqual(iceServers); + }), + ); + + it.effect("rejects a URL under the wrong environment setting", () => + Effect.gen(function* () { + const error = yield* Effect.flip(validateStunUrls(["turn:turn.example.test:3478"])); + expect(error).toBeInstanceOf(WebRtcCandidatePolicyError); + expect(error.code).toBe("unsupported-stun-url"); + }), + ); + + it.effect("rejects incomplete TURN credentials", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + validateIceServers([ + { urls: ["turn:turn.example.test:3478"], username: "missing-credential" }, + ]), + ); + expect(error.code).toBe("incomplete-turn-credentials"); + }), + ); + + it.effect("allows relay candidates in SDP", () => + validateSessionDescription( + "v=0\r\na=candidate:1 1 UDP 1677734910 203.0.113.1 50000 typ relay\r\n", + ), + ); + + it.effect("rejects oversized SDP without throwing", () => + Effect.gen(function* () { + const error = yield* Effect.flip( + validateSessionDescription("v=0\r\n", { + maxBytes: 2, + }), + ); + expect(error.code).toBe("sdp-too-large"); + }), + ); }); diff --git a/packages/shared/src/webrtcCandidatePolicy.ts b/packages/shared/src/webrtcCandidatePolicy.ts index ba9715ed6..24ed7ce8e 100644 --- a/packages/shared/src/webrtcCandidatePolicy.ts +++ b/packages/shared/src/webrtcCandidatePolicy.ts @@ -1,78 +1,130 @@ +import type { WebRtcIceServer } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + export const DEFAULT_WEBRTC_STUN_URLS = ["stun:stun.cloudflare.com:3478"] as const; export const MAX_WEBRTC_SDP_BYTES = 256 * 1024; export const MAX_WEBRTC_SDP_CANDIDATES = 128; export type WebRtcCandidatePolicyErrorCode = - | "turn-url" - | "unsupported-url" - | "relay-candidate" + | "unsupported-stun-url" + | "unsupported-turn-url" + | "unsupported-ice-url" + | "incomplete-turn-credentials" | "sdp-too-large" | "too-many-candidates"; -export class WebRtcCandidatePolicyError extends Error { - readonly code: WebRtcCandidatePolicyErrorCode; - - constructor(code: WebRtcCandidatePolicyErrorCode, message: string) { - super(message); - this.name = "WebRtcCandidatePolicyError"; - this.code = code; +export class WebRtcCandidatePolicyError extends Schema.TaggedErrorClass()( + "WebRtcCandidatePolicyError", + { + code: Schema.Literals([ + "unsupported-stun-url", + "unsupported-turn-url", + "unsupported-ice-url", + "incomplete-turn-credentials", + "sdp-too-large", + "too-many-candidates", + ]), + }, +) { + override get message(): string { + switch (this.code) { + case "unsupported-stun-url": + return "WebRTC STUN servers must use the stun: or stuns: scheme."; + case "unsupported-turn-url": + return "WebRTC TURN servers must use the turn: or turns: scheme."; + case "unsupported-ice-url": + return "WebRTC ICE servers must use the stun:, stuns:, turn:, or turns: scheme."; + case "incomplete-turn-credentials": + return "WebRTC TURN username and credential must be configured together."; + case "sdp-too-large": + return "WebRTC SDP exceeds the size limit."; + case "too-many-candidates": + return "WebRTC SDP contains too many ICE candidates."; + default: { + const exhaustive: never = this.code; + return exhaustive; + } + } } } const STUN_URL_PATTERN = /^stuns?:[^\s]+$/i; -const TURN_URL_PATTERN = /^turns?:/i; -const RELAY_CANDIDATE_PATTERN = /(?:^|\s)typ\s+relay(?:\s|$)/i; +const TURN_URL_PATTERN = /^turns?:[^\s]+$/i; +const ICE_URL_PATTERN = /^(?:stuns?|turns?):[^\s]+$/i; const SDP_CANDIDATE_PATTERN = /^a=candidate:/gm; -export function validateStunUrls(urls: ReadonlyArray): ReadonlyArray { - return urls.map((value) => { +export const validateStunUrls = Effect.fn("WebRtcCandidatePolicy.validateStunUrls")(function* ( + urls: ReadonlyArray, +) { + const validated: Array = []; + for (const value of urls) { const url = value.trim(); - if (TURN_URL_PATTERN.test(url)) { - throw new WebRtcCandidatePolicyError("turn-url", "TURN URLs are not allowed."); - } if (!STUN_URL_PATTERN.test(url)) { - throw new WebRtcCandidatePolicyError( - "unsupported-url", - "WebRTC ICE servers must use the stun: or stuns: scheme.", - ); + return yield* new WebRtcCandidatePolicyError({ code: "unsupported-stun-url" }); } - return url; - }); -} + validated.push(url); + } + return validated; +}); + +export const validateTurnUrls = Effect.fn("WebRtcCandidatePolicy.validateTurnUrls")(function* ( + urls: ReadonlyArray, +) { + const validated: Array = []; + for (const value of urls) { + const url = value.trim(); + if (!TURN_URL_PATTERN.test(url)) { + return yield* new WebRtcCandidatePolicyError({ code: "unsupported-turn-url" }); + } + validated.push(url); + } + return validated; +}); -export function validateIceCandidate(candidate: string): void { - if (RELAY_CANDIDATE_PATTERN.test(candidate)) { - throw new WebRtcCandidatePolicyError( - "relay-candidate", - "Relay ICE candidates are not allowed.", - ); +export const validateIceServers = Effect.fn("WebRtcCandidatePolicy.validateIceServers")(function* ( + iceServers: ReadonlyArray, +) { + const validated: Array = []; + for (const iceServer of iceServers) { + const urls: Array = []; + for (const value of iceServer.urls) { + const url = value.trim(); + if (!ICE_URL_PATTERN.test(url)) { + return yield* new WebRtcCandidatePolicyError({ code: "unsupported-ice-url" }); + } + urls.push(url); + } + if ((iceServer.username === undefined) !== (iceServer.credential === undefined)) { + return yield* new WebRtcCandidatePolicyError({ code: "incomplete-turn-credentials" }); + } + validated.push({ + urls, + ...(iceServer.username === undefined + ? {} + : { username: iceServer.username, credential: iceServer.credential }), + }); } -} + return validated; +}); -export function validateSessionDescription( +export const validateSessionDescription = Effect.fn( + "WebRtcCandidatePolicy.validateSessionDescription", +)(function* ( sdp: string, options?: { readonly maxBytes?: number; readonly maxCandidates?: number; }, -): void { +) { const maxBytes = options?.maxBytes ?? MAX_WEBRTC_SDP_BYTES; const maxCandidates = options?.maxCandidates ?? MAX_WEBRTC_SDP_CANDIDATES; if (new TextEncoder().encode(sdp).byteLength > maxBytes) { - throw new WebRtcCandidatePolicyError("sdp-too-large", "WebRTC SDP exceeds the size limit."); + return yield* new WebRtcCandidatePolicyError({ code: "sdp-too-large" }); } const candidateCount = [...sdp.matchAll(SDP_CANDIDATE_PATTERN)].length; if (candidateCount > maxCandidates) { - throw new WebRtcCandidatePolicyError( - "too-many-candidates", - "WebRTC SDP contains too many ICE candidates.", - ); + return yield* new WebRtcCandidatePolicyError({ code: "too-many-candidates" }); } - if (RELAY_CANDIDATE_PATTERN.test(sdp)) { - throw new WebRtcCandidatePolicyError( - "relay-candidate", - "WebRTC SDP contains a relay ICE candidate.", - ); - } -} +}); diff --git a/packages/shared/src/webrtcDataChannel.test.ts b/packages/shared/src/webrtcDataChannel.test.ts index 0b398e5b3..268f88009 100644 --- a/packages/shared/src/webrtcDataChannel.test.ts +++ b/packages/shared/src/webrtcDataChannel.test.ts @@ -157,7 +157,11 @@ it.effect("allocates a new message ID when a write effect is reused", () => yield* write; const decoder = new WebRtcMessageReassembler(); - expect(port.sent.map((frame) => decoder.push(frame, 0)?.messageId)).toEqual([1, 2]); + const messageIds: Array = []; + for (const frame of port.sent) { + messageIds.push((yield* decoder.push(frame, 0))?.messageId); + } + expect(messageIds).toEqual([1, 2]); }), ); @@ -195,7 +199,7 @@ it.effect("expires an idle partial message without waiting for another frame", ( const exitFiber = yield* receiver.socket .run(() => undefined) .pipe(Effect.exit, Effect.forkScoped); - const frames = encodeWebRtcMessage({ + const frames = yield* encodeWebRtcMessage({ kind: "rpc", messageId: 1, payload: new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES + 1), diff --git a/packages/shared/src/webrtcDataChannel.ts b/packages/shared/src/webrtcDataChannel.ts index 73aad3fe4..aed01cbdb 100644 --- a/packages/shared/src/webrtcDataChannel.ts +++ b/packages/shared/src/webrtcDataChannel.ts @@ -9,7 +9,6 @@ import * as Socket from "effect/unstable/socket/Socket"; import { encodeWebRtcMessage, type DecodedWebRtcMessage, - WebRtcFramingError, WebRtcMessageReassembler, } from "./webrtcFraming.ts"; @@ -116,10 +115,13 @@ export const makeWebRtcDataChannelConnection = Effect.fn("WebRtcDataChannelConne Effect.sleep(Duration.millis(Math.max(0, expiresAtMs - nowMs))).pipe( Effect.andThen(Clock.currentTimeMillis), Effect.flatMap((expiredAtMs) => - Effect.try({ - try: () => decoder.expirePartials(expiredAtMs), - catch: (cause) => socketError(new Socket.SocketReadError({ cause })), - }), + decoder + .expirePartials(expiredAtMs) + .pipe( + Effect.mapError((cause) => + socketError(new Socket.SocketReadError({ cause })), + ), + ), ), Effect.tapError(failConnection), Effect.andThen(Effect.never), @@ -128,16 +130,7 @@ export const makeWebRtcDataChannelConnection = Effect.fn("WebRtcDataChannelConne Effect.raceFirst(takeFrame), ); const nowMs = yield* Clock.currentTimeMillis; - const message = yield* Effect.try({ - try: () => decoder.push(frame, nowMs), - catch: (cause) => - cause instanceof WebRtcFramingError - ? cause - : new WebRtcFramingError( - "invalid-header", - "WebRTC framing decoder failed unexpectedly.", - ), - }).pipe( + const message = yield* decoder.push(frame, nowMs).pipe( Effect.tapError((error) => Effect.logWarning("WebRTC framing rejected a DataChannel frame.").pipe( Effect.annotateLogs({ "webrtc.framing.error_code": error.code }), @@ -171,10 +164,10 @@ export const makeWebRtcDataChannelConnection = Effect.fn("WebRtcDataChannelConne ); const sendControl = (kind: "binding" | "binding-ack", payload: Uint8Array) => - Effect.try({ - try: () => encodeWebRtcMessage({ kind, messageId: 0, payload }), - catch: (cause) => socketError(new Socket.SocketWriteError({ cause })), - }).pipe(Effect.flatMap(sendFrames)); + encodeWebRtcMessage({ kind, messageId: 0, payload }).pipe( + Effect.mapError((cause) => socketError(new Socket.SocketWriteError({ cause }))), + Effect.flatMap(sendFrames), + ); const expectControl = Effect.fn("WebRtcDataChannelConnection.expectControl")(function* ( expected: "binding" | "binding-ack", @@ -217,14 +210,15 @@ export const makeWebRtcDataChannelConnection = Effect.fn("WebRtcDataChannelConne return Effect.sync(() => port.close()); } const payload = typeof chunk === "string" ? new TextEncoder().encode(chunk) : chunk; - return Effect.try({ - try: () => { - const messageId = nextMessageId; - nextMessageId = nextMessageId === 0xffff_ffff ? 1 : nextMessageId + 1; - return encodeWebRtcMessage({ kind: "rpc", messageId, payload }); - }, - catch: (cause) => socketError(new Socket.SocketWriteError({ cause })), - }).pipe(Effect.flatMap(sendFrames)); + return Effect.sync(() => { + const messageId = nextMessageId; + nextMessageId = nextMessageId === 0xffff_ffff ? 1 : nextMessageId + 1; + return messageId; + }).pipe( + Effect.flatMap((messageId) => encodeWebRtcMessage({ kind: "rpc", messageId, payload })), + Effect.mapError((cause) => socketError(new Socket.SocketWriteError({ cause }))), + Effect.flatMap(sendFrames), + ); }), }); diff --git a/packages/shared/src/webrtcFraming.test.ts b/packages/shared/src/webrtcFraming.test.ts index f3075af98..9a9292836 100644 --- a/packages/shared/src/webrtcFraming.test.ts +++ b/packages/shared/src/webrtcFraming.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; import { encodeWebRtcMessage, @@ -8,81 +9,111 @@ import { WebRtcMessageReassembler, } from "./webrtcFraming.ts"; -function reassemble(frames: ReadonlyArray) { +const reassemble = Effect.fn("WebRtcFraming.test.reassemble")(function* ( + frames: ReadonlyArray, +) { const reassembler = new WebRtcMessageReassembler(); let result = null; for (const frame of frames) { - result = reassembler.push(frame, 0); + result = yield* reassembler.push(frame, 0); } return result; -} +}); describe("WebRTC RPC framing", () => { - it("fragments and reassembles a large RPC message", () => { - const payload = new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES * 3 + 41); - payload.fill(77); - const frames = encodeWebRtcMessage({ kind: "rpc", messageId: 1, payload }); + it.effect("fragments and reassembles a large RPC message", () => + Effect.gen(function* () { + const payload = new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES * 3 + 41); + payload.fill(77); + const frames = yield* encodeWebRtcMessage({ kind: "rpc", messageId: 1, payload }); + + expect(frames).toHaveLength(4); + expect(yield* reassemble(frames)).toEqual({ kind: "rpc", messageId: 1, payload }); + }), + ); - expect(frames).toHaveLength(4); - expect(reassemble(frames)).toEqual({ kind: "rpc", messageId: 1, payload }); - }); + it.effect("reassembles sequential messages", () => + Effect.gen(function* () { + const decoder = new WebRtcMessageReassembler(); + const first = new TextEncoder().encode("first"); + const second = new TextEncoder().encode("second"); + const firstFrames = yield* encodeWebRtcMessage({ + kind: "rpc", + messageId: 1, + payload: first, + }); + const secondFrames = yield* encodeWebRtcMessage({ + kind: "rpc", + messageId: 2, + payload: second, + }); - it("reassembles sequential messages", () => { - const decoder = new WebRtcMessageReassembler(); - const first = new TextEncoder().encode("first"); - const second = new TextEncoder().encode("second"); + expect(yield* decoder.push(firstFrames[0]!, 0)).toEqual({ + kind: "rpc", + messageId: 1, + payload: first, + }); + expect(yield* decoder.push(secondFrames[0]!, 0)).toEqual({ + kind: "rpc", + messageId: 2, + payload: second, + }); + }), + ); - expect( - decoder.push(encodeWebRtcMessage({ kind: "rpc", messageId: 1, payload: first })[0]!, 0), - ).toEqual({ kind: "rpc", messageId: 1, payload: first }); - expect( - decoder.push(encodeWebRtcMessage({ kind: "rpc", messageId: 2, payload: second })[0]!, 0), - ).toEqual({ kind: "rpc", messageId: 2, payload: second }); - }); + it.effect("rejects malformed, overlapping, duplicate, and oversized frames", () => + Effect.gen(function* () { + const payload = new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES + 5); + const frames = yield* encodeWebRtcMessage({ kind: "rpc", messageId: 5, payload }); + const malformed = frames[0]!.slice(0, 8); + expect(yield* Effect.flip(new WebRtcMessageReassembler().push(malformed, 0))).toBeInstanceOf( + WebRtcFramingError, + ); - it("rejects malformed, overlapping, duplicate, and oversized frames", () => { - const payload = new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES + 5); - const frames = encodeWebRtcMessage({ kind: "rpc", messageId: 5, payload }); - const malformed = frames[0]!.slice(0, 8); - expect(() => new WebRtcMessageReassembler().push(malformed, 0)).toThrowError( - WebRtcFramingError, - ); + const overlapping = frames[1]!.slice(); + new DataView(overlapping.buffer).setUint32(16, 1); + const overlapDecoder = new WebRtcMessageReassembler(); + yield* overlapDecoder.push(frames[0]!, 0); + expect(yield* Effect.flip(overlapDecoder.push(overlapping, 0))).toBeInstanceOf( + WebRtcFramingError, + ); - const overlapping = frames[1]!.slice(); - new DataView(overlapping.buffer).setUint32(16, 1); - const overlapDecoder = new WebRtcMessageReassembler(); - overlapDecoder.push(frames[0]!, 0); - expect(() => overlapDecoder.push(overlapping, 0)).toThrowError(WebRtcFramingError); + const duplicateDecoder = new WebRtcMessageReassembler(); + for (const frame of frames) { + yield* duplicateDecoder.push(frame, 0); + } + expect(yield* Effect.flip(duplicateDecoder.push(frames[0]!, 0))).toBeInstanceOf( + WebRtcFramingError, + ); - const duplicateDecoder = new WebRtcMessageReassembler(); - for (const frame of frames) { - duplicateDecoder.push(frame, 0); - } - expect(() => duplicateDecoder.push(frames[0]!, 0)).toThrowError(WebRtcFramingError); + expect( + yield* Effect.flip( + encodeWebRtcMessage({ + kind: "rpc", + messageId: 9, + payload: new Uint8Array(WEBRTC_RPC_MAX_MESSAGE_BYTES + 1), + }), + ), + ).toBeInstanceOf(WebRtcFramingError); + }), + ); - expect(() => - encodeWebRtcMessage({ + it.effect("expires partial messages", () => + Effect.gen(function* () { + const decoder = new WebRtcMessageReassembler({ partialTtlMs: 10 }); + const partial = yield* encodeWebRtcMessage({ kind: "rpc", - messageId: 9, - payload: new Uint8Array(WEBRTC_RPC_MAX_MESSAGE_BYTES + 1), - }), - ).toThrowError(WebRtcFramingError); - }); - - it("expires partial messages", () => { - const decoder = new WebRtcMessageReassembler({ partialTtlMs: 10 }); - const partial = encodeWebRtcMessage({ - kind: "rpc", - messageId: 1, - payload: new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES + 1), - }); - const next = encodeWebRtcMessage({ - kind: "rpc", - messageId: 2, - payload: new Uint8Array([1]), - }); + messageId: 1, + payload: new Uint8Array(WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES + 1), + }); + const next = yield* encodeWebRtcMessage({ + kind: "rpc", + messageId: 2, + payload: new Uint8Array([1]), + }); - expect(decoder.push(partial[0]!, 0)).toBeNull(); - expect(() => decoder.push(next[0]!, 11)).toThrowError(WebRtcFramingError); - }); + expect(yield* decoder.push(partial[0]!, 0)).toBeNull(); + expect(yield* Effect.flip(decoder.push(next[0]!, 11))).toBeInstanceOf(WebRtcFramingError); + }), + ); }); diff --git a/packages/shared/src/webrtcFraming.ts b/packages/shared/src/webrtcFraming.ts index d3628758d..593b0d93e 100644 --- a/packages/shared/src/webrtcFraming.ts +++ b/packages/shared/src/webrtcFraming.ts @@ -1,3 +1,6 @@ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + export const WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES = 12 * 1024; export const WEBRTC_RPC_MAX_MESSAGE_BYTES = 8 * 1024 * 1024; export const WEBRTC_RPC_MAX_PARTIAL_MESSAGES = 8; @@ -27,29 +30,63 @@ export interface DecodedWebRtcMessage { readonly payload: Uint8Array; } -export type WebRtcFramingErrorCode = - | "frame-too-small" - | "invalid-header" - | "invalid-kind" - | "invalid-flags" - | "fragment-too-large" - | "message-too-large" - | "invalid-bounds" - | "missing-start" - | "duplicate-message" - | "too-many-partials" - | "partial-expired" - | "overlapping-fragment" - | "mismatched-message" - | "invalid-control-frame"; - -export class WebRtcFramingError extends Error { - readonly code: WebRtcFramingErrorCode; +export const WebRtcFramingErrorCode = Schema.Literals([ + "frame-too-small", + "invalid-header", + "invalid-kind", + "invalid-flags", + "fragment-too-large", + "message-too-large", + "invalid-bounds", + "missing-start", + "duplicate-message", + "too-many-partials", + "partial-expired", + "overlapping-fragment", + "mismatched-message", + "invalid-control-frame", +]); +export type WebRtcFramingErrorCode = typeof WebRtcFramingErrorCode.Type; - constructor(code: WebRtcFramingErrorCode, message: string) { - super(message); - this.name = "WebRtcFramingError"; - this.code = code; +export class WebRtcFramingError extends Schema.TaggedErrorClass()( + "WebRtcFramingError", + { code: WebRtcFramingErrorCode }, +) { + override get message(): string { + switch (this.code) { + case "frame-too-small": + return "WebRTC frame is shorter than its header."; + case "invalid-header": + return "WebRTC frame header is invalid."; + case "invalid-kind": + return "Unknown WebRTC frame kind."; + case "invalid-flags": + return "WebRTC frame flags are invalid."; + case "fragment-too-large": + return "WebRTC fragment exceeds the size limit."; + case "message-too-large": + return "WebRTC message exceeds the size limit."; + case "invalid-bounds": + return "WebRTC fragment is out of bounds."; + case "missing-start": + return "WebRTC message is missing its start frame."; + case "duplicate-message": + return "WebRTC message contains duplicate data."; + case "too-many-partials": + return "WebRTC has too many partial messages."; + case "partial-expired": + return "WebRTC partial message exceeded its lifetime."; + case "overlapping-fragment": + return "WebRTC fragment overlaps or skips existing message data."; + case "mismatched-message": + return "WebRTC fragment does not match its partial message."; + case "invalid-control-frame": + return "WebRTC control frame has an invalid shape."; + default: { + const exhaustive: never = this.code; + return exhaustive; + } + } } } @@ -61,7 +98,7 @@ interface PartialMessage { receivedLength: number; } -function frameKindFromCode(code: number): WebRtcFrameKind { +const frameKindFromCode = Effect.fn("WebRtcFraming.frameKindFromCode")(function* (code: number) { switch (code) { case 1: return "binding"; @@ -70,9 +107,9 @@ function frameKindFromCode(code: number): WebRtcFrameKind { case 3: return "rpc"; default: - throw new WebRtcFramingError("invalid-kind", "Unknown WebRTC frame kind."); + return yield* new WebRtcFramingError({ code: "invalid-kind" }); } -} +}); function makeFrame(input: { readonly kind: WebRtcFrameKind; @@ -102,21 +139,18 @@ function makeFrame(input: { return frame; } -export function encodeWebRtcMessage(input: { +export const encodeWebRtcMessage = Effect.fn("WebRtcFraming.encodeMessage")(function* (input: { readonly kind: WebRtcFrameKind; readonly messageId: number; readonly payload: Uint8Array; readonly fragmentPayloadBytes?: number; -}): ReadonlyArray { +}) { const fragmentPayloadBytes = input.fragmentPayloadBytes ?? WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES; if (fragmentPayloadBytes <= 0 || fragmentPayloadBytes > WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES) { - throw new WebRtcFramingError( - "fragment-too-large", - "WebRTC fragment payload size is outside the supported range.", - ); + return yield* new WebRtcFramingError({ code: "fragment-too-large" }); } if (input.payload.byteLength > WEBRTC_RPC_MAX_MESSAGE_BYTES) { - throw new WebRtcFramingError("message-too-large", "WebRTC message exceeds the size limit."); + return yield* new WebRtcFramingError({ code: "message-too-large" }); } if (input.kind !== "rpc") { if ( @@ -124,10 +158,7 @@ export function encodeWebRtcMessage(input: { input.payload.byteLength > WEBRTC_BINDING_MAX_BYTES || (input.kind === "binding-ack" && input.payload.byteLength !== 0) ) { - throw new WebRtcFramingError( - "invalid-control-frame", - "WebRTC control frame has an invalid shape.", - ); + return yield* new WebRtcFramingError({ code: "invalid-control-frame" }); } return [ makeFrame({ @@ -142,7 +173,7 @@ export function encodeWebRtcMessage(input: { ]; } if (input.messageId === 0) { - throw new WebRtcFramingError("invalid-header", "WebRTC RPC message ID must be non-zero."); + return yield* new WebRtcFramingError({ code: "invalid-header" }); } if (input.payload.byteLength === 0) { return [ @@ -174,7 +205,7 @@ export function encodeWebRtcMessage(input: { ); } return frames; -} +}); export class WebRtcMessageReassembler { readonly #maxMessageBytes: number; @@ -205,7 +236,10 @@ export class WebRtcMessageReassembler { return nextExpiryAtMs; } - expirePartials(nowMs: number): void { + readonly expirePartials = Effect.fn("WebRtcMessageReassembler.expirePartials")(function* ( + this: WebRtcMessageReassembler, + nowMs: number, + ) { let expired = false; for (const [messageId, partial] of this.#partials) { if (nowMs >= partial.createdAtMs + this.#partialTtlMs) { @@ -214,17 +248,18 @@ export class WebRtcMessageReassembler { } } if (expired) { - throw new WebRtcFramingError( - "partial-expired", - "WebRTC partial message exceeded its lifetime.", - ); + return yield* new WebRtcFramingError({ code: "partial-expired" }); } - } + }); - push(frame: Uint8Array, nowMs: number): DecodedWebRtcMessage | null { - this.expirePartials(nowMs); + readonly push = Effect.fn("WebRtcMessageReassembler.push")(function* ( + this: WebRtcMessageReassembler, + frame: Uint8Array, + nowMs: number, + ) { + yield* this.expirePartials(nowMs); if (frame.byteLength < FRAME_HEADER_BYTES) { - throw new WebRtcFramingError("frame-too-small", "WebRTC frame is shorter than its header."); + return yield* new WebRtcFramingError({ code: "frame-too-small" }); } const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); if ( @@ -232,35 +267,35 @@ export class WebRtcMessageReassembler { view.getUint8(4) !== FRAME_VERSION || view.getUint8(7) !== 0 ) { - throw new WebRtcFramingError("invalid-header", "WebRTC frame header is invalid."); + return yield* new WebRtcFramingError({ code: "invalid-header" }); } - const kind = frameKindFromCode(view.getUint8(5)); + const kind = yield* frameKindFromCode(view.getUint8(5)); const flags = view.getUint8(6); if ((flags & ~KNOWN_FLAGS) !== 0) { - throw new WebRtcFramingError("invalid-flags", "WebRTC frame has unknown flags."); + return yield* new WebRtcFramingError({ code: "invalid-flags" }); } const start = (flags & START_FLAG) !== 0; const end = (flags & END_FLAG) !== 0; const control = (flags & CONTROL_FLAG) !== 0; if (control !== (kind !== "rpc")) { - throw new WebRtcFramingError("invalid-flags", "WebRTC frame control flag is invalid."); + return yield* new WebRtcFramingError({ code: "invalid-flags" }); } const messageId = view.getUint32(8); const totalLength = view.getUint32(12); const offset = view.getUint32(16); const payload = frame.subarray(FRAME_HEADER_BYTES); if (payload.byteLength > WEBRTC_RPC_FRAGMENT_PAYLOAD_BYTES) { - throw new WebRtcFramingError("fragment-too-large", "WebRTC fragment exceeds the size limit."); + return yield* new WebRtcFramingError({ code: "fragment-too-large" }); } if (totalLength > this.#maxMessageBytes) { - throw new WebRtcFramingError("message-too-large", "WebRTC message exceeds the size limit."); + return yield* new WebRtcFramingError({ code: "message-too-large" }); } if (offset > totalLength || payload.byteLength > totalLength - offset) { - throw new WebRtcFramingError("invalid-bounds", "WebRTC fragment is out of bounds."); + return yield* new WebRtcFramingError({ code: "invalid-bounds" }); } const reachesEnd = offset + payload.byteLength === totalLength; if (start !== (offset === 0) || end !== reachesEnd) { - throw new WebRtcFramingError("invalid-flags", "WebRTC fragment boundary flags are invalid."); + return yield* new WebRtcFramingError({ code: "invalid-flags" }); } if (kind !== "rpc") { @@ -271,27 +306,24 @@ export class WebRtcMessageReassembler { totalLength > WEBRTC_BINDING_MAX_BYTES || (kind === "binding-ack" && totalLength !== 0) ) { - throw new WebRtcFramingError( - "invalid-control-frame", - "WebRTC control frame has an invalid shape.", - ); + return yield* new WebRtcFramingError({ code: "invalid-control-frame" }); } - return { kind, messageId, payload: payload.slice() }; + return { kind, messageId, payload: payload.slice() } satisfies DecodedWebRtcMessage; } if (messageId === 0) { - throw new WebRtcFramingError("invalid-header", "WebRTC RPC message ID must be non-zero."); + return yield* new WebRtcFramingError({ code: "invalid-header" }); } if (this.#completedMessageIds.has(messageId)) { - throw new WebRtcFramingError("duplicate-message", "WebRTC message ID was already completed."); + return yield* new WebRtcFramingError({ code: "duplicate-message" }); } let partial = this.#partials.get(messageId); if (partial === undefined) { if (!start) { - throw new WebRtcFramingError("missing-start", "WebRTC message is missing its start frame."); + return yield* new WebRtcFramingError({ code: "missing-start" }); } if (this.#partials.size >= this.#maxPartialMessages) { - throw new WebRtcFramingError("too-many-partials", "WebRTC has too many partial messages."); + return yield* new WebRtcFramingError({ code: "too-many-partials" }); } partial = { kind, @@ -303,20 +335,14 @@ export class WebRtcMessageReassembler { this.#partials.set(messageId, partial); } else { if (start) { - throw new WebRtcFramingError("duplicate-message", "WebRTC message has a duplicate start."); + return yield* new WebRtcFramingError({ code: "duplicate-message" }); } if (partial.kind !== kind || partial.totalLength !== totalLength) { - throw new WebRtcFramingError( - "mismatched-message", - "WebRTC fragment does not match its partial message.", - ); + return yield* new WebRtcFramingError({ code: "mismatched-message" }); } } if (offset !== partial.receivedLength) { - throw new WebRtcFramingError( - "overlapping-fragment", - "WebRTC fragment overlaps or skips existing message data.", - ); + return yield* new WebRtcFramingError({ code: "overlapping-fragment" }); } partial.chunks.push(payload.slice()); partial.receivedLength += payload.byteLength; @@ -339,6 +365,6 @@ export class WebRtcMessageReassembler { this.#completedMessageIds.delete(oldest); } } - return { kind: "rpc", messageId, payload: message }; - } + return { kind: "rpc", messageId, payload: message } satisfies DecodedWebRtcMessage; + }); } From 3b143c450fabc584ac044f9b258ef3daf9822ee3 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 10 Aug 2026 19:24:25 +0300 Subject: [PATCH 5/9] fix(rpc): preserve WebRTC close causes --- packages/client-runtime/src/connection/model.ts | 1 + packages/client-runtime/src/rpc/session.test.ts | 1 + packages/client-runtime/src/rpc/session.ts | 3 ++- 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/client-runtime/src/connection/model.ts b/packages/client-runtime/src/connection/model.ts index fbcb302ed..bc8c64fd7 100644 --- a/packages/client-runtime/src/connection/model.ts +++ b/packages/client-runtime/src/connection/model.ts @@ -81,6 +81,7 @@ export class ConnectionTransientError extends Schema.TaggedErrorClass { rtc.clientPort.close(); const closeError = yield* Fiber.join(firstClosed); expect(closeError).toMatchObject({ reason: "transport" }); + expect(closeError.cause).toMatchObject({ _tag: "WebRtcFastPathTransportClosedError" }); const secondSession = yield* factory.connect(PREPARED); const secondReady = yield* Effect.forkChild(secondSession.ready); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index e381c68ca..bebfab78e 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -192,10 +192,11 @@ export const make = Effect.gen(function* () { }), ), Effect.mapError( - () => + (cause) => new ConnectionTransientErrorClass({ reason: "transport", detail: `${connection.label} WebRTC transport disconnected.`, + cause, }), ), ); From 1aa190a2bc4e47d4404a8724201f5f1a332b239c Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 10 Aug 2026 19:58:41 +0300 Subject: [PATCH 6/9] feat(web): show RPC transport status --- apps/web/src/components/BranchToolbar.tsx | 2 + .../BranchToolbarEnvironmentSelector.tsx | 3 + .../components/EnvironmentConnectionBadge.tsx | 78 +++++++++++++++++++ apps/web/src/state/session.ts | 12 +++ packages/client-runtime/src/rpc/index.ts | 2 +- packages/client-runtime/src/rpc/session.ts | 8 +- packages/client-runtime/src/state/session.ts | 41 ++++++++++ 7 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/EnvironmentConnectionBadge.tsx diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5ceec8131..180e0b9c7 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -28,6 +28,7 @@ import { import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; +import { EnvironmentConnectionBadge } from "./EnvironmentConnectionBadge"; import { Button } from "./ui/button"; import { Menu, @@ -111,6 +112,7 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ + ) : ( diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 56fb91fb4..301e848f2 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 { EnvironmentConnectionBadge } from "./EnvironmentConnectionBadge"; import { Select, SelectGroup, @@ -54,6 +55,7 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir ) : ( )} + )} + + + + ); + + return ( + + + +
+
Status
+
{statusDetail}
+
RPC transport
+
{transportLabel}
+ {transport !== null ? ( + <> +
Control channel
+
WebSocket
+ + ) : null} +
Environment
+
{environment?.label ?? environmentId}
+
+
+
+ ); +} diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index a7d5a53d1..44edcee08 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -1,5 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { createEnvironmentSessionAtoms } from "@t3tools/client-runtime/state/session"; +import type { RpcTransport } from "@t3tools/client-runtime/rpc"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -12,6 +13,9 @@ 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"), +); export function usePreparedConnection(environmentId: EnvironmentId | null) { return useAtomValue( @@ -27,6 +31,14 @@ export function readPreparedConnection(environmentId: EnvironmentId) { ); } +export function useEnvironmentRpcTransport(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentId === null + ? EMPTY_RPC_TRANSPORT_ATOM + : environmentSession.rpcTransportValueAtom(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/packages/client-runtime/src/rpc/index.ts b/packages/client-runtime/src/rpc/index.ts index e755d9bed..d59384c8a 100644 --- a/packages/client-runtime/src/rpc/index.ts +++ b/packages/client-runtime/src/rpc/index.ts @@ -1,7 +1,7 @@ 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"; export { makeWebRtcPeerFactory, selectedIcePairTypeFromStats, diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index bebfab78e..fdea72ad4 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -35,14 +35,16 @@ import { const SOCKET_OPEN_TIMEOUT = "15 seconds"; +export type RpcTransport = "websocket" | "webrtc"; + export interface RpcSession { readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; readonly ready: Effect.Effect; readonly probe: Effect.Effect; readonly closed: Effect.Effect; - readonly transport?: "websocket" | "webrtc"; - readonly transportChanges?: Stream.Stream<"websocket" | "webrtc">; + readonly transport?: RpcTransport; + readonly transportChanges?: Stream.Stream; } export class RpcSessionFactory extends Context.Service< @@ -140,7 +142,7 @@ export const make = Effect.gen(function* () { ); interface SelectedTransport { - readonly kind: "websocket" | "webrtc"; + readonly kind: RpcTransport; readonly client: WsRpcProtocolClient; readonly initialConfig: Effect.Effect; readonly closed: Effect.Effect; diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 31fd297da..79cf79b81 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -13,6 +13,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 +123,44 @@ 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) => { + const currentTransport = session.transport ?? "websocket"; + return session.transportChanges === undefined + ? Stream.succeed(currentTransport) + : Stream.concat( + Stream.succeed(currentTransport), + session.transportChanges, + ); + }, + }), + ), + ), + ), + ), + ), + ), + { 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}`)), + ); + // 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 +195,8 @@ export function createEnvironmentSessionAtoms( initialConfigValueAtom, preparedConnectionAtom, preparedConnectionValueAtom, + rpcTransportAtom, + rpcTransportValueAtom, sessionStateAtom, sessionStateValueAtom, }; From f3567b32edbe44662529cb9ca5d4262d09b38b61 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 10 Aug 2026 20:25:49 +0300 Subject: [PATCH 7/9] fix(rpc): decode STUN-only WebRTC capability --- packages/contracts/src/webrtc.test.ts | 51 +++++++++++++++++++++++++++ packages/contracts/src/webrtc.ts | 37 ++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 packages/contracts/src/webrtc.test.ts diff --git a/packages/contracts/src/webrtc.test.ts b/packages/contracts/src/webrtc.test.ts new file mode 100644 index 000000000..990edafd7 --- /dev/null +++ b/packages/contracts/src/webrtc.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Schema from "effect/Schema"; + +import { WebRtcRpcFastPathCapability } from "./webrtc.ts"; + +const decodeWebRtcRpcFastPathCapability = Schema.decodeUnknownSync(WebRtcRpcFastPathCapability); + +describe("WebRtcRpcFastPathCapability", () => { + it("decodes the current configurable ICE server shape", () => { + const capability = decodeWebRtcRpcFastPathCapability({ + version: 1, + signaling: "same-websocket-rpc", + iceServers: [ + { urls: ["stun:stun.example.test"] }, + { + urls: ["turn:turn.example.test"], + username: "user", + credential: "secret", + }, + ], + }); + + expect(capability.iceServers).toEqual([ + { urls: ["stun:stun.example.test"] }, + { + urls: ["turn:turn.example.test"], + username: "user", + credential: "secret", + }, + ]); + }); + + it("normalizes the original STUN-only v1 shape", () => { + const capability = decodeWebRtcRpcFastPathCapability({ + version: 1, + signaling: "same-websocket-rpc", + turn: false, + stunUrls: ["stun:stun.example.test", "stun:backup.example.test"], + }); + + expect(capability).toEqual({ + version: 1, + signaling: "same-websocket-rpc", + iceServers: [ + { + urls: ["stun:stun.example.test", "stun:backup.example.test"], + }, + ], + }); + }); +}); diff --git a/packages/contracts/src/webrtc.ts b/packages/contracts/src/webrtc.ts index 58209d20a..55df2a97a 100644 --- a/packages/contracts/src/webrtc.ts +++ b/packages/contracts/src/webrtc.ts @@ -1,4 +1,5 @@ import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; import { IsoDateTime, TrimmedNonEmptyString } from "./baseSchemas.ts"; @@ -9,11 +10,45 @@ export const WebRtcIceServer = Schema.Struct({ }); export type WebRtcIceServer = typeof WebRtcIceServer.Type; -export const WebRtcRpcFastPathCapability = Schema.Struct({ +const WebRtcRpcFastPathCapabilityCurrent = Schema.Struct({ version: Schema.Literal(1), signaling: Schema.Literal("same-websocket-rpc"), iceServers: Schema.Array(WebRtcIceServer), }); + +const WebRtcRpcFastPathCapabilityStunOnlyEncoded = Schema.Struct({ + version: Schema.Literal(1), + signaling: Schema.Literal("same-websocket-rpc"), + turn: Schema.Literal(false), + stunUrls: Schema.Array(TrimmedNonEmptyString), +}); + +const WebRtcRpcFastPathCapabilityStunOnly = WebRtcRpcFastPathCapabilityStunOnlyEncoded.pipe( + Schema.decodeTo( + WebRtcRpcFastPathCapabilityCurrent, + SchemaTransformation.transform< + typeof WebRtcRpcFastPathCapabilityCurrent.Encoded, + typeof WebRtcRpcFastPathCapabilityStunOnlyEncoded.Type + >({ + decode: (capability) => ({ + version: capability.version, + signaling: capability.signaling, + iceServers: capability.stunUrls.length === 0 ? [] : [{ urls: capability.stunUrls }], + }), + encode: (capability) => ({ + version: capability.version, + signaling: capability.signaling, + turn: false, + stunUrls: capability.iceServers.flatMap((server) => server.urls), + }), + }), + ), +); + +export const WebRtcRpcFastPathCapability = Schema.Union([ + WebRtcRpcFastPathCapabilityCurrent, + WebRtcRpcFastPathCapabilityStunOnly, +]); export type WebRtcRpcFastPathCapability = typeof WebRtcRpcFastPathCapability.Type; export const WebRtcNegotiateInput = Schema.Struct({ From 001ef28470deddfac7f4ddacaaef0c691ee7ec20 Mon Sep 17 00:00:00 2001 From: tarik02 Date: Mon, 10 Aug 2026 22:45:13 +0300 Subject: [PATCH 8/9] fix(web): always show RPC transport status --- .../components/BranchToolbar.logic.test.ts | 39 ----------- .../web/src/components/BranchToolbar.logic.ts | 11 ---- apps/web/src/components/BranchToolbar.tsx | 65 +++++++++--------- .../BranchToolbarEnvironmentSelector.tsx | 66 +++++++++---------- apps/web/src/components/ChatView.tsx | 6 +- .../components/EnvironmentConnectionBadge.tsx | 26 ++++---- apps/web/src/components/ui/tooltip.tsx | 7 +- 7 files changed, 86 insertions(+), 134 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 36d42a60f..94638d14b 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -18,7 +18,6 @@ import { resolvePreviousWorktreeSeed, shouldIncludeBranchPickerItem, shouldShowComposerContextStrip, - shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; const localEnvironmentId = EnvironmentId.make("environment-local"); @@ -384,44 +383,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 485ffbf8d..82f74c5ec 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 180e0b9c7..cd0d79caa 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -9,7 +9,7 @@ import { HistoryIcon, MonitorIcon, } from "lucide-react"; -import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { memo, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useProject, useThread, useThreadShellsForProjectRefs } from "../state/entities"; @@ -23,12 +23,11 @@ import { resolveLockedWorkspaceLabel, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, - shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { BranchToolbarBranchSelector } from "./BranchToolbarBranchSelector"; import { BranchToolbarEnvironmentSelector } from "./BranchToolbarEnvironmentSelector"; import { BranchToolbarEnvModeSelector } from "./BranchToolbarEnvModeSelector"; -import { EnvironmentConnectionBadge } from "./EnvironmentConnectionBadge"; +import { EnvironmentConnectionStatus } from "./EnvironmentConnectionBadge"; import { Button } from "./ui/button"; import { Menu, @@ -105,44 +104,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 ? ( <> @@ -391,10 +397,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 301e848f2..4a296e7e4 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -3,7 +3,7 @@ import { CloudIcon, MonitorIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; -import { EnvironmentConnectionBadge } from "./EnvironmentConnectionBadge"; +import { EnvironmentConnectionStatus } from "./EnvironmentConnectionBadge"; import { Select, SelectGroup, @@ -18,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; } @@ -49,20 +49,19 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir // only thing in the strip. if (envLocked || onEnvironmentChange === undefined) { return ( - - {activeEnvironment?.isPrimary ? ( - - ) : ( - + + {(indicator) => ( + + {indicator} + + {activeEnvironment?.label ?? "Run on"} + + )} - - - {activeEnvironment?.label ?? "Run on"} - - + ); } @@ -73,25 +72,24 @@ 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 29d1123e4..dc85cb113 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -259,7 +259,6 @@ import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, shouldShowComposerContextStrip, - shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; import { getProviderStatusBannerKey, @@ -1877,10 +1876,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/EnvironmentConnectionBadge.tsx b/apps/web/src/components/EnvironmentConnectionBadge.tsx index aa7e11f2f..e7f663080 100644 --- a/apps/web/src/components/EnvironmentConnectionBadge.tsx +++ b/apps/web/src/components/EnvironmentConnectionBadge.tsx @@ -1,6 +1,7 @@ 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 { useEnvironment } from "~/state/environments"; import { useEnvironmentRpcTransport } from "~/state/session"; @@ -8,11 +9,15 @@ import { useEnvironmentRpcTransport } from "~/state/session"; import { Badge } from "./ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -interface EnvironmentConnectionBadgeProps { +interface EnvironmentConnectionStatusProps { readonly environmentId: EnvironmentId; + readonly children: (indicator: ReactNode) => ReactElement; } -export function EnvironmentConnectionBadge({ environmentId }: EnvironmentConnectionBadgeProps) { +export function EnvironmentConnectionStatus({ + environmentId, + children, +}: EnvironmentConnectionStatusProps) { const environment = useEnvironment(environmentId); const transport = useEnvironmentRpcTransport(environmentId); const phase = environment?.connection.phase ?? "available"; @@ -25,10 +30,10 @@ export function EnvironmentConnectionBadge({ environmentId }: EnvironmentConnect ? "WebSocket" : "Not connected"; - let variant: "error" | "secondary" | "success" | "warning" = "secondary"; + let variant: "error" | "info" | "secondary" | "success" | "warning" = "secondary"; switch (phase) { case "connected": - variant = "success"; + variant = transport === "webrtc" ? "info" : "success"; break; case "connecting": case "reconnecting": @@ -43,21 +48,16 @@ export function EnvironmentConnectionBadge({ environmentId }: EnvironmentConnect } const TransportIcon = transport === "webrtc" ? RadioTowerIcon : CableIcon; - const badge = ( - + const indicator = ( + ); return ( - - + +
Status
{statusDetail}
diff --git a/apps/web/src/components/ui/tooltip.tsx b/apps/web/src/components/ui/tooltip.tsx index 5a891990c..6505fb60d 100644 --- a/apps/web/src/components/ui/tooltip.tsx +++ b/apps/web/src/components/ui/tooltip.tsx @@ -18,6 +18,7 @@ function TooltipPopup({ sideOffset = 4, side = "top", variant = "default", + hoverable = false, anchor, children, ...props @@ -26,6 +27,7 @@ function TooltipPopup({ side?: TooltipPrimitive.Positioner.Props["side"]; sideOffset?: TooltipPrimitive.Positioner.Props["sideOffset"]; variant?: "default" | "glass"; + hoverable?: boolean; anchor?: TooltipPrimitive.Positioner.Props["anchor"]; }) { return ( @@ -33,7 +35,10 @@ function TooltipPopup({ Date: Mon, 10 Aug 2026 22:45:22 +0300 Subject: [PATCH 9/9] fix(rpc): keep WebRTC alive after control disconnect --- apps/server/src/server.test.ts | 166 ++++++++++-------- .../webrtc/WebRtcFastPathController.test.ts | 37 ++++ .../src/webrtc/WebRtcFastPathController.ts | 32 +++- .../src/webrtc/WebRtcSessionSupervisor.ts | 31 ++++ apps/server/src/ws.ts | 118 ++++++++----- docs/internals/webrtc-rpc-fast-path.md | 12 +- .../client-runtime/src/rpc/session.test.ts | 15 +- packages/client-runtime/src/rpc/session.ts | 23 ++- 8 files changed, 305 insertions(+), 129 deletions(-) create mode 100644 apps/server/src/webrtc/WebRtcSessionSupervisor.ts diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index a9b950bc6..07b688ae4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -980,16 +980,22 @@ const parseSessionCookieFromWsUrl = ( }; }; -const wsRpcProtocolLayer = (wsUrl: string) => { +const wsRpcProtocolLayer = ( + wsUrl: string, + onSocket?: (socket: NodeSocket.NodeWS.WebSocket) => void, +) => { const { cookie, url } = parseSessionCookieFromWsUrl(wsUrl); const webSocketConstructorLayer = Layer.succeed( Socket.WebSocketConstructor, - (socketUrl, protocols) => - new NodeSocket.NodeWS.WebSocket( + (socketUrl, protocols) => { + const socket = new NodeSocket.NodeWS.WebSocket( socketUrl, protocols, cookie ? { headers: { cookie } } : undefined, - ) as unknown as globalThis.WebSocket, + ); + onSocket?.(socket); + return socket as unknown as globalThis.WebSocket; + }, ); return RpcClient.layerProtocolSocket().pipe( @@ -1005,7 +1011,8 @@ type WsRpcClient = const withWsRpcClient = ( wsUrl: string, f: (client: WsRpcClient) => Effect.Effect, -) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl))); + onSocket?: (socket: NodeSocket.NodeWS.WebSocket) => void, +) => makeWsRpcClient.pipe(Effect.flatMap(f), Effect.provide(wsRpcProtocolLayer(wsUrl, onSocket))); const appendSessionCookieToWsUrl = (url: string, sessionCookieHeader: string) => { const isAbsoluteUrl = /^[a-zA-Z][a-zA-Z\d+.-]*:/.test(url); @@ -4404,78 +4411,93 @@ it.layer(NodeServices.layer)("server router seam", (it) => { weriftDataChannelPort(dataChannel), ); const wsUrl = yield* getWsServerUrl("/ws"); + const controlSocket = yield* Deferred.make(); + + const controlEnvironmentId = yield* Effect.scoped( + withWsRpcClient( + wsUrl, + (controlClient) => + Effect.gen(function* () { + const controlConfig = yield* controlClient[WS_METHODS.serverGetConfig]({}); + assert.deepEqual(controlConfig.environment.capabilities.webRtcRpcFastPath, { + version: 1, + signaling: "same-websocket-rpc", + iceServers: [], + }); - yield* Effect.scoped( - withWsRpcClient(wsUrl, (controlClient) => - Effect.gen(function* () { - const controlConfig = yield* controlClient[WS_METHODS.serverGetConfig]({}); - assert.deepEqual(controlConfig.environment.capabilities.webRtcRpcFastPath, { - version: 1, - signaling: "same-websocket-rpc", - iceServers: [], - }); - - const offer = yield* Effect.tryPromise({ - try: async () => { - const pendingOffer = await clientPeer.createOffer(); - await clientPeer.setLocalDescription(pendingOffer); - return clientPeer.localDescription; - }, - catch: (cause) => new ServerWebRtcPeerError({ stage: "offer", cause }), - }); - if (offer === null || offer.type !== "offer") { - return yield* Effect.die(new Error("WebRTC client did not produce an offer.")); - } - const answer = yield* controlClient[WS_METHODS.transportWebRtcNegotiate]({ - version: 1, - attemptId: "authenticated-attempt", - offerSdp: offer.sdp, - }); - yield* Effect.tryPromise({ - try: () => clientPeer.setRemoteDescription({ type: "answer", sdp: answer.answerSdp }), - catch: (cause) => new ServerWebRtcPeerError({ stage: "offer", cause }), - }); - yield* rtcConnection.awaitOpen.pipe(Effect.timeout("10 seconds")); - yield* rtcConnection.sendBinding( - new TextEncoder().encode( - encodeWebRtcBindingFrame({ - version: 1, - attemptId: answer.attemptId, - bindingToken: answer.bindingToken, - }), - ), - ); - yield* rtcConnection.awaitBindingAck.pipe(Effect.timeout("10 seconds")); - - const rtcProtocolContext = yield* Layer.build( - Layer.effect( - RpcClient.Protocol, - RpcClient.makeProtocolSocket({ retryTransientErrors: false }), - ).pipe( - Layer.provide( - Layer.mergeAll( - Layer.succeed(Socket.Socket, rtcConnection.socket), - RpcSerialization.layerJson, - ), + const offer = yield* Effect.tryPromise({ + try: async () => { + const pendingOffer = await clientPeer.createOffer(); + await clientPeer.setLocalDescription(pendingOffer); + return clientPeer.localDescription; + }, + catch: (cause) => new ServerWebRtcPeerError({ stage: "offer", cause }), + }); + if (offer === null || offer.type !== "offer") { + return yield* Effect.die(new Error("WebRTC client did not produce an offer.")); + } + const answer = yield* controlClient[WS_METHODS.transportWebRtcNegotiate]({ + version: 1, + attemptId: "authenticated-attempt", + offerSdp: offer.sdp, + }); + yield* Effect.tryPromise({ + try: () => + clientPeer.setRemoteDescription({ type: "answer", sdp: answer.answerSdp }), + catch: (cause) => new ServerWebRtcPeerError({ stage: "offer", cause }), + }); + yield* rtcConnection.awaitOpen.pipe(Effect.timeout("10 seconds")); + yield* rtcConnection.sendBinding( + new TextEncoder().encode( + encodeWebRtcBindingFrame({ + version: 1, + attemptId: answer.attemptId, + bindingToken: answer.bindingToken, + }), ), - ), - ); - const rtcClient = yield* makeWsRpcClient.pipe(Effect.provide(rtcProtocolContext)); - assert.deepEqual(yield* rtcClient[WS_METHODS.serverProbe]({}), {}); - const rtcConfig = yield* rtcClient[WS_METHODS.serverGetConfig]({}); - assert.equal( - rtcConfig.environment.environmentId, - controlConfig.environment.environmentId, - ); - const shellItem = yield* rtcClient[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe( - Stream.runHead, - Effect.map(Option.getOrThrow), - ); - assert.equal(shellItem.kind, "snapshot"); - }), + ); + yield* rtcConnection.awaitBindingAck.pipe(Effect.timeout("10 seconds")); + const socket = yield* Deferred.await(controlSocket); + yield* Effect.callback((resume) => { + const onClose = () => resume(Effect.void); + socket.once("close", onClose); + socket.close(4000, "control disconnect verification"); + return Effect.sync(() => socket.off("close", onClose)); + }); + return controlConfig.environment.environmentId; + }), + (socket) => Deferred.doneUnsafe(controlSocket, Effect.succeed(socket)), + ), + ); + + assert.equal(yield* Deferred.isDone(rtcClosed), false); + const rtcProtocolContext = yield* Layer.build( + Layer.effect( + RpcClient.Protocol, + RpcClient.makeProtocolSocket({ retryTransientErrors: false }), + ).pipe( + Layer.provide( + Layer.mergeAll( + Layer.succeed(Socket.Socket, rtcConnection.socket), + RpcSerialization.layerJson, + ), + ), ), ); + const rtcClient = yield* makeWsRpcClient.pipe(Effect.provide(rtcProtocolContext)); + assert.deepEqual(yield* rtcClient[WS_METHODS.serverProbe]({}), {}); + const rtcConfig = yield* rtcClient[WS_METHODS.serverGetConfig]({}); + assert.equal(rtcConfig.environment.environmentId, controlEnvironmentId); + const shellItem = yield* rtcClient[ORCHESTRATION_WS_METHODS.subscribeShell]({}).pipe( + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + assert.equal(shellItem.kind, "snapshot"); + yield* Effect.tryPromise({ + try: () => clientPeer.close(), + catch: (cause) => new ServerWebRtcPeerError({ stage: "connection", cause }), + }); yield* Deferred.await(rtcClosed).pipe(Effect.timeout("10 seconds")); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/webrtc/WebRtcFastPathController.test.ts b/apps/server/src/webrtc/WebRtcFastPathController.test.ts index 16a67957d..ee37c3363 100644 --- a/apps/server/src/webrtc/WebRtcFastPathController.test.ts +++ b/apps/server/src/webrtc/WebRtcFastPathController.test.ts @@ -247,6 +247,43 @@ describe("WebRtcFastPathController", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("keeps a bound DataChannel alive after the control WebSocket closes", () => + Effect.gen(function* () { + const harness = yield* makeHarness(); + const answer = yield* harness.controller.negotiate({ + version: 1, + attemptId: "attempt-1", + offerSdp: "v=0\r\n", + }); + yield* Deferred.succeed(harness.channels, harness.serverPort); + const client = yield* makeWebRtcDataChannelConnection(harness.clientPort); + yield* client.sendBinding( + new TextEncoder().encode( + encodeBinding({ + version: 1, + attemptId: answer.attemptId, + bindingToken: answer.bindingToken, + }), + ), + ); + yield* client.awaitBindingAck; + yield* Queue.take(harness.accepted); + + const sessionEnd = yield* harness.controller.awaitSessionEndAfterControlClose.pipe( + Effect.forkChild, + ); + yield* Effect.yieldNow; + + expect(sessionEnd.pollUnsafe()).toBeUndefined(); + expect(harness.serverPort.isOpen()).toBe(true); + expect(yield* Ref.get(harness.closeCount)).toBe(0); + + harness.serverPort.close(); + yield* Fiber.join(sessionEnd); + expect(yield* Ref.get(harness.closeCount)).toBe(1); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect("closes a peer whose binding token does not match", () => Effect.gen(function* () { const harness = yield* makeHarness(); diff --git a/apps/server/src/webrtc/WebRtcFastPathController.ts b/apps/server/src/webrtc/WebRtcFastPathController.ts index 450d5c5e1..c30363976 100644 --- a/apps/server/src/webrtc/WebRtcFastPathController.ts +++ b/apps/server/src/webrtc/WebRtcFastPathController.ts @@ -17,6 +17,7 @@ import { makeWebRtcDataChannelConnection } from "@t3tools/shared/webrtcDataChann import * as Clock from "effect/Clock"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -41,6 +42,7 @@ interface ActiveAttempt { readonly bindingToken: string; readonly expiresAtMs: number; readonly peer: ServerWebRtcPeer; + readonly closed: Deferred.Deferred; readonly bound: boolean; } @@ -50,6 +52,7 @@ export interface WebRtcFastPathController { input: WebRtcNegotiateInput, ) => Effect.Effect; readonly abort: (attemptId: string) => Effect.Effect<{}, WebRtcSignalingError>; + readonly awaitSessionEndAfterControlClose: Effect.Effect; readonly close: Effect.Effect; } @@ -91,7 +94,11 @@ export const makeWebRtcFastPathController = Effect.fn("WebRtcFastPathController. : null; const closeAttempt = (attempt: ActiveAttempt | null) => - attempt === null ? Effect.void : attempt.peer.close; + attempt === null + ? Effect.void + : attempt.peer.close.pipe( + Effect.ensuring(Deferred.succeed(attempt.closed, undefined).pipe(Effect.asVoid)), + ); const clearAttempt = Effect.fn("WebRtcFastPathController.clearAttempt")(function* ( attemptId: string, ) { @@ -164,7 +171,9 @@ export const makeWebRtcFastPathController = Effect.fn("WebRtcFastPathController. ), ); return yield* Effect.raceFirst(attempt.peer.closed, connection.closed).pipe( - Effect.ensuring(logStats), + Effect.ensuring( + Deferred.succeed(attempt.closed, undefined).pipe(Effect.asVoid, Effect.andThen(logStats)), + ), ); }); @@ -246,11 +255,13 @@ export const makeWebRtcFastPathController = Effect.fn("WebRtcFastPathController. ); const bindingStartedAtMs = yield* Clock.currentTimeMillis; const expiresAtMs = bindingStartedAtMs + attemptTtlMs; + const closed = yield* Deferred.make(); const attempt: ActiveAttempt = { attemptId: input.attemptId, bindingToken, expiresAtMs, peer, + closed, bound: false, }; yield* Ref.set(active, attempt); @@ -317,12 +328,29 @@ export const makeWebRtcFastPathController = Effect.fn("WebRtcFastPathController. }); const close = Ref.getAndSet(active, null).pipe(Effect.flatMap(closeAttempt)); + const awaitSessionEndAfterControlClose = Effect.gen(function* () { + const current = yield* Ref.get(active); + if (current === null || current.bound === false) { + yield* close; + return; + } + yield* Effect.logDebug( + "Control WebSocket closed; keeping the selected WebRTC RPC transport alive.", + ).pipe( + Effect.annotateLogs({ + "rpc.transport": "webrtc", + "webrtc.control_websocket": "closed", + }), + ); + yield* Deferred.await(current.closed); + }); yield* Effect.addFinalizer(() => close); return { capability, negotiate, abort, + awaitSessionEndAfterControlClose, close, } satisfies WebRtcFastPathController; }); diff --git a/apps/server/src/webrtc/WebRtcSessionSupervisor.ts b/apps/server/src/webrtc/WebRtcSessionSupervisor.ts new file mode 100644 index 000000000..90548ebfb --- /dev/null +++ b/apps/server/src/webrtc/WebRtcSessionSupervisor.ts @@ -0,0 +1,31 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import type * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Scope from "effect/Scope"; + +export class WebRtcSessionSupervisor extends Context.Service< + WebRtcSessionSupervisor, + { + readonly forkScope: Effect.Effect; + readonly fork: ( + effect: Effect.Effect, + ) => Effect.Effect, never, R>; + } +>()("t3/webrtc/WebRtcSessionSupervisor") {} + +export const makeWebRtcSessionSupervisor = Effect.fn("WebRtcSessionSupervisor.make")(function* () { + const parentScope = yield* Scope.Scope; + + return WebRtcSessionSupervisor.of({ + forkScope: Scope.fork(parentScope, "sequential"), + fork(effect: Effect.Effect) { + return Effect.forkIn(effect, parentScope, { startImmediately: true }); + }, + }); +}); + +export const WebRtcSessionSupervisorLive = Layer.effect( + WebRtcSessionSupervisor, + makeWebRtcSessionSupervisor(), +); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7a643dd4c..a0b791385 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3,11 +3,14 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, @@ -131,6 +134,10 @@ import { import { loadServerWebRtcRuntime } from "./webrtc/WebRtcPeer.ts"; import { RpcTransport, type RpcTransportKind } from "./webrtc/RpcTransport.ts"; import { makeSingleSocketServer } from "./webrtc/SingleSocketServer.ts"; +import { + WebRtcSessionSupervisor, + WebRtcSessionSupervisorLive, +} from "./webrtc/WebRtcSessionSupervisor.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); @@ -2283,6 +2290,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.gen(function* () { const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; + const webRtcSessionSupervisor = yield* WebRtcSessionSupervisor; return HttpRouter.add( "GET", "/ws", @@ -2299,52 +2307,74 @@ export const websocketRpcRouteLayer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); - const socketServer = yield* makeSingleSocketServer(); - const webRtcRuntime = - config.webRtcFastPathEnabled === true ? yield* loadServerWebRtcRuntime : Option.none(); - const webRtc = yield* makeWebRtcFastPathController({ - enabled: config.webRtcFastPathEnabled === true, - iceServers: config.webRtcIceServers ?? [], - ...(config.webRtcUdpPortRange === undefined - ? {} - : { udpPortRange: config.webRtcUdpPortRange }), - runtime: webRtcRuntime, - socketServer, - }); - const websocketHandlers = makeWsRpcHandlerLayer({ - session, - previewAutomationBroker, - serverSelfUpdate, - webRtc, - transport: "websocket", - }); - const webRtcHandlers = makeWsRpcHandlerLayer({ - session, - previewAutomationBroker, - serverSelfUpdate, - webRtc, - transport: "webrtc", - }); - yield* Layer.build( - RpcServer.layer(WsRpcGroup, { disableTracing: true }).pipe( - Layer.provide(webRtcHandlers), - Layer.provide(RpcServer.layerProtocolSocketServer), - Layer.provide( - Layer.mergeAll( - Layer.succeed(SocketServer.SocketServer, socketServer.server), - RpcSerialization.layerJson, + const sessionScope = yield* webRtcSessionSupervisor.forkScope; + const lifecycleFiber = yield* Effect.gen(function* () { + const socketServer = yield* makeSingleSocketServer(); + const webRtcRuntime = + config.webRtcFastPathEnabled === true ? yield* loadServerWebRtcRuntime : Option.none(); + const webRtc = yield* makeWebRtcFastPathController({ + enabled: config.webRtcFastPathEnabled === true, + iceServers: config.webRtcIceServers ?? [], + ...(config.webRtcUdpPortRange === undefined + ? {} + : { udpPortRange: config.webRtcUdpPortRange }), + runtime: webRtcRuntime, + socketServer, + }); + const websocketHandlers = makeWsRpcHandlerLayer({ + session, + previewAutomationBroker, + serverSelfUpdate, + webRtc, + transport: "websocket", + }); + const webRtcHandlers = makeWsRpcHandlerLayer({ + session, + previewAutomationBroker, + serverSelfUpdate, + webRtc, + transport: "webrtc", + }); + yield* Layer.build( + RpcServer.layer(WsRpcGroup, { disableTracing: true }).pipe( + Layer.provide(webRtcHandlers), + Layer.provide(RpcServer.layerProtocolSocketServer), + Layer.provide( + Layer.mergeAll( + Layer.succeed(SocketServer.SocketServer, socketServer.server), + RpcSerialization.layerJson, + ), ), ), - ), - ); - const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { - disableTracing: true, - }).pipe(Effect.provide(Layer.mergeAll(websocketHandlers, RpcSerialization.layerJson))); - return yield* Effect.acquireUseRelease( - sessions.markConnected(session.sessionId), - () => rpcWebSocketHttpEffect, - () => webRtc.close.pipe(Effect.andThen(sessions.markDisconnected(session.sessionId))), + ); + const rpcWebSocketHttpEffect = yield* RpcServer.toHttpEffectWebsocket(WsRpcGroup, { + disableTracing: true, + }).pipe(Effect.provide(Layer.mergeAll(websocketHandlers, RpcSerialization.layerJson))); + const lifecycle = Effect.acquireUseRelease( + sessions.markConnected(session.sessionId), + () => + Effect.gen(function* () { + const controlExit = yield* Effect.exit(rpcWebSocketHttpEffect); + if (controlExit._tag === "Failure" && Cause.hasInterruptsOnly(controlExit.cause)) { + return yield* Effect.failCause(controlExit.cause); + } + yield* webRtc.awaitSessionEndAfterControlClose; + if (controlExit._tag === "Failure") { + return yield* Effect.failCause(controlExit.cause); + } + return controlExit.value; + }), + (_, exit) => + Scope.close(sessionScope, exit).pipe( + Effect.andThen(sessions.markDisconnected(session.sessionId)), + ), + ); + return yield* webRtcSessionSupervisor.fork(lifecycle); + }).pipe( + Scope.provide(sessionScope), + Effect.onError((cause) => Scope.close(sessionScope, Exit.failCause(cause))), ); + return yield* Fiber.join(lifecycleFiber); }).pipe( Effect.catchTags({ EnvironmentAuthInvalidError: HttpServerRespondable.toResponse, @@ -2353,4 +2383,4 @@ export const websocketRpcRouteLayer = Layer.unwrap( ), ); }), -); +).pipe(Layer.provide(WebRtcSessionSupervisorLive)); diff --git a/docs/internals/webrtc-rpc-fast-path.md b/docs/internals/webrtc-rpc-fast-path.md index 80ad07048..0b0b43e4d 100644 --- a/docs/internals/webrtc-rpc-fast-path.md +++ b/docs/internals/webrtc-rpc-fast-path.md @@ -12,11 +12,13 @@ DataChannel to the authenticated WebSocket with a short-lived attempt ID and a one-time random token. The client selects WebRTC only after `server.probe` and a matching `server.getConfig` succeed over the DataChannel. -The control WebSocket remains open and authoritative for authentication, -signaling, presence, and lifetime. Signaling calls made over WebRTC are rejected. -If the control WebSocket closes, the server and client close its DataChannel. If -a selected DataChannel closes, the normal environment supervisor replaces the -whole session. It does not replay requests or move in-flight streams between +The control WebSocket remains the authentication and signaling channel while it +is connected. Signaling calls made over WebRTC are rejected. Once the binding +frame and RTC probe succeed, the authenticated server session can continue over +the DataChannel if the control WebSocket later disconnects. Negotiation that has +not reached that point still ends with the WebSocket. If the selected +DataChannel closes, the normal environment supervisor replaces the whole +session. It does not replay requests or move in-flight streams between transports. ## ICE configuration and support diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index f172b17b8..d0d3432b0 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -452,7 +452,7 @@ describe("RpcSessionFactory", () => { }), ); - it.effect("reaches WebSocket readiness before selecting WebRTC", () => + it.effect("reaches WebSocket readiness and keeps selected WebRTC after control closes", () => Effect.gen(function* () { const rtc = yield* makeRtcHarness(); const { factory, sockets } = yield* makeFactory(rtc.peerFactory); @@ -487,8 +487,17 @@ describe("RpcSessionFactory", () => { const closedFiber = yield* Effect.flip(session.closed).pipe(Effect.forkChild); socket.close(1012, "service restart"); - yield* Fiber.join(closedFiber); - expect(rtc.clientPort.isOpen()).toBe(false); + yield* Effect.yieldNow; + + expect(closedFiber.pollUnsafe()).toBeUndefined(); + expect(rtc.clientPort.isOpen()).toBe(true); + const probe = yield* session.probe.pipe(Effect.forkChild); + expect(yield* Queue.take(rtc.rtcRequests)).toBe(WS_METHODS.serverProbe); + yield* Fiber.join(probe); + + rtc.clientPort.close(); + const closeError = yield* Fiber.join(closedFiber); + expect(closeError).toMatchObject({ reason: "transport" }); }), ); diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index fdea72ad4..bf91804b6 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -263,10 +263,27 @@ export const make = Effect.gen(function* () { Effect.withSpan("clientRuntime.connection.rpcSession.probe"), ); }); - const closed = Effect.raceFirst( - Deferred.await(disconnected).pipe( - Effect.tapError(() => Effect.suspend(() => SubscriptionRef.getUnsafe(selected).close)), + const selectedTransportAfterControlClose = Deferred.await(disconnected).pipe( + Effect.catchTag("ConnectionTransientError", (error) => + Effect.suspend(() => { + const transport = SubscriptionRef.getUnsafe(selected); + if (transport.kind === "websocket") { + return Effect.fail(error); + } + return Effect.logDebug( + "Control WebSocket closed; keeping the selected WebRTC RPC transport alive.", + ).pipe( + Effect.annotateLogs({ + "rpc.transport": "webrtc", + "webrtc.control_websocket": "closed", + }), + Effect.andThen(Effect.never), + ); + }), ), + ); + const closed = Effect.raceFirst( + selectedTransportAfterControlClose, Deferred.await(upgradeComplete).pipe( Effect.andThen( Effect.suspend(() => {