feat: enable FORCE_TURN_RELAY to diagnose turn connectivity for local deployment setups (#272)

* filter out local sdp candidates on non local environment

* feat: add FORCE_TURN_RELAY variable

* add FORCE_TURN_RELAY option in docker-compose

* fix: fix github workflow
This commit is contained in:
Abhishek 2026-05-11 17:13:01 +05:30 committed by GitHub
parent 01c201bf09
commit e2fe1f3cd4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 410 additions and 37 deletions

View file

@ -12,6 +12,8 @@ export async function GET() {
let apiVersion = "unknown";
let deploymentMode = "oss";
let authProvider = "local";
let turnEnabled = false;
let forceTurnRelay = false;
try {
const response = await healthApiV1HealthGet();
@ -20,6 +22,8 @@ export async function GET() {
apiVersion = data.version;
deploymentMode = data.deployment_mode;
authProvider = data.auth_provider;
turnEnabled = Boolean(data.turn_enabled);
forceTurnRelay = Boolean(data.force_turn_relay);
}
} catch {
apiVersion = "unavailable";
@ -30,5 +34,7 @@ export async function GET() {
api: apiVersion,
deploymentMode,
authProvider,
turnEnabled,
forceTurnRelay,
});
}

View file

@ -4,6 +4,7 @@ import { client } from "@/client/client.gen";
import { getTurnCredentialsApiV1TurnCredentialsGet, validateUserConfigurationsApiV1UserConfigurationsUserValidateGet, validateWorkflowApiV1WorkflowWorkflowIdValidatePost } from "@/client/sdk.gen";
import { TurnCredentialsResponse } from "@/client/types.gen";
import { WorkflowValidationError } from "@/components/flow/types";
import { useAppConfig } from "@/context/AppConfigContext";
import logger from '@/lib/logger';
import { sdpFilterCodec } from "../utils";
@ -48,6 +49,7 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
const [isStarting, setIsStarting] = useState(false);
const [feedbackMessages, setFeedbackMessages] = useState<FeedbackMessage[]>([]);
const initialContext = initialContextVariables || {};
const { config: appConfig } = useAppConfig();
const {
audioInputs,
@ -123,6 +125,16 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
iceServers
};
// Diagnostic: when the backend is started with FORCE_TURN_RELAY=true,
// restrict the browser to relay-only candidates so media must traverse
// TURN. Lets you verify TURN connectivity end-to-end — a TURN
// misconfiguration surfaces as an ICE failure instead of silently
// falling back to host/srflx.
if (appConfig?.forceTurnRelay) {
config.iceTransportPolicy = 'relay';
logger.info('FORCE_TURN_RELAY is on — restricting browser ICE to relay candidates only');
}
const pc = new RTCPeerConnection(config);
// Set up ICE candidate trickling
@ -528,24 +540,30 @@ export const useWebSocketRTC = ({ workflowId, workflowRunId, accessToken, initia
setConnectionStatus('connecting');
try {
// Fetch time-limited TURN credentials from backend API
try {
const turnResponse = await getTurnCredentialsApiV1TurnCredentialsGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (turnResponse.data) {
turnCredentialsRef.current = turnResponse.data;
logger.info(`TURN credentials obtained, TTL: ${turnResponse.data.ttl}s`);
} else if (turnResponse.response.status === 503) {
// TURN not configured on server - this is OK, we'll use STUN only
logger.info('TURN server not configured, using STUN only');
} else {
logger.warn(`Failed to fetch TURN credentials: ${turnResponse.response.status}`);
// Fetch time-limited TURN credentials from backend API only if the
// server reports a TURN server is configured. Skipping the request
// avoids a 503 on OSS local deployments that don't run coturn.
if (appConfig?.turnEnabled === false) {
logger.info('TURN server disabled in app config, using STUN only');
} else {
try {
const turnResponse = await getTurnCredentialsApiV1TurnCredentialsGet({
headers: {
'Authorization': `Bearer ${accessToken}`,
},
});
if (turnResponse.data) {
turnCredentialsRef.current = turnResponse.data;
logger.info(`TURN credentials obtained, TTL: ${turnResponse.data.ttl}s`);
} else if (turnResponse.response.status === 503) {
// TURN not configured on server - this is OK, we'll use STUN only
logger.info('TURN server not configured, using STUN only');
} else {
logger.warn(`Failed to fetch TURN credentials: ${turnResponse.response.status}`);
}
} catch (e) {
logger.warn('Failed to fetch TURN credentials, continuing without TURN:', e);
}
} catch (e) {
logger.warn('Failed to fetch TURN credentials, continuing without TURN:', e);
}
// Validate API keys

View file

@ -1812,6 +1812,14 @@ export type HealthResponse = {
* Auth Provider
*/
auth_provider: string;
/**
* Turn Enabled
*/
turn_enabled: boolean;
/**
* Force Turn Relay
*/
force_turn_relay: boolean;
};
/**

View file

@ -7,6 +7,8 @@ interface AppConfig {
apiVersion: string;
deploymentMode: string;
authProvider: string;
turnEnabled: boolean;
forceTurnRelay: boolean;
}
interface AppConfigContextType {
@ -19,6 +21,8 @@ const defaultConfig: AppConfig = {
apiVersion: 'unknown',
deploymentMode: 'oss',
authProvider: 'local',
turnEnabled: false,
forceTurnRelay: false,
};
const AppConfigContext = createContext<AppConfigContextType>({
@ -39,6 +43,8 @@ export function AppConfigProvider({ children }: { children: ReactNode }) {
apiVersion: data.api || 'unknown',
deploymentMode: data.deploymentMode || 'oss',
authProvider: data.authProvider || 'local',
turnEnabled: Boolean(data.turnEnabled),
forceTurnRelay: Boolean(data.forceTurnRelay),
});
})
.catch(() => {