feat: implement CORS proxy for Matrix homeservers during development and refactor call handling logic

This commit is contained in:
2026-04-20 22:23:42 +10:00
parent 13a0c98d1d
commit 8060d50a6f
8 changed files with 221 additions and 48 deletions

View File

@@ -328,6 +328,7 @@ export class CallService {
// Step 2: Try fetching LiveKit JWT from homeservers in priority order // Step 2: Try fetching LiveKit JWT from homeservers in priority order
const result = await fetchLiveKitJWTFromServers( const result = await fetchLiveKitJWTFromServers(
homeserverPriority, homeserverPriority,
this.config.homeserverBaseUrl,
roomId, roomId,
this.config.userId, this.config.userId,
this.config.deviceId, this.config.deviceId,

View File

@@ -152,7 +152,8 @@ export interface CallEvent {
* Call service configuration * Call service configuration
*/ */
export interface CallServiceConfig { export interface CallServiceConfig {
livekitServiceUrl: string; /** @deprecated No longer used - JWT fetched dynamically from multiple homeservers */
livekitServiceUrl?: string;
homeserverBaseUrl: string; homeserverBaseUrl: string;
accessToken: string; accessToken: string;
userId: string; userId: string;

View File

@@ -82,26 +82,12 @@ export function useCallService(): CallContextValue {
} }
try { try {
// Get LiveKit service URL from Matrix homeserver well-known // Initialize CallService without checking user's homeserver
const wellKnown = await fetchWellKnownWithRTC(baseUrl); // Call support will be determined per-room based on available homeservers
// eslint-disable-next-line no-console console.log('Call service init - Initializing (call support determined per-room)');
console.log('Call service init - well-known:', wellKnown);
const livekitFocus = getLiveKitFocus(wellKnown);
// eslint-disable-next-line no-console
console.log('Call service init - LiveKit focus:', livekitFocus);
const livekitServiceUrl = livekitFocus?.livekit_service_url;
if (!livekitServiceUrl) {
// eslint-disable-next-line no-console
console.log('Call service init - No LiveKit config in well-known, calls not supported');
setCallSupported(false);
setCallSupportLoading(false);
return;
}
service = new CallService( service = new CallService(
{ {
livekitServiceUrl,
homeserverBaseUrl: baseUrl, homeserverBaseUrl: baseUrl,
accessToken, accessToken,
userId, userId,
@@ -112,10 +98,10 @@ export function useCallService(): CallContextValue {
setCallService(service); setCallService(service);
setGlobalCallService(service); // Make available globally for Paarrot API setGlobalCallService(service); // Make available globally for Paarrot API
setCallSupported(true); setCallSupported(true); // Global flag - actual support checked per-room
setCallSupportLoading(false); setCallSupportLoading(false);
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log('Call service init - Success! Calls are supported'); console.log('Call service init - Success!');
unsubscribe = service.on((event: CallEvent) => { unsubscribe = service.on((event: CallEvent) => {
// Update state for any event that might change the call state // Update state for any event that might change the call state
@@ -245,8 +231,62 @@ export function useCallService(): CallContextValue {
* Hook to check if a call is active in a specific room * Hook to check if a call is active in a specific room
*/ */
export function useRoomCall(roomId: string) { export function useRoomCall(roomId: string) {
const { activeCall, startCall, endCall, callSupported, callSupportLoading, callService } = useCall(); const { activeCall, startCall, endCall, callSupported: globalCallSupported, callSupportLoading: globalCallSupportLoading, callService } = useCall();
const mx = useMatrixClient(); const mx = useMatrixClient();
const [roomCallSupported, setRoomCallSupported] = useState(false);
const [roomCallSupportLoading, setRoomCallSupportLoading] = useState(true);
// Check if calls are supported for this specific room by checking all priority homeservers
useEffect(() => {
const checkRoomCallSupport = async () => {
setRoomCallSupportLoading(true);
const room = mx.getRoom(roomId);
if (!room) {
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
return;
}
// Get homeserver priority list for this room
const homeserverPriority = getLiveKitHomeserverPriority(
room,
mx.getHomeserverUrl(),
mx.getSafeUserId()
);
console.log(`Checking call support for room ${roomId}, homeserver priority:`, homeserverPriority);
// Check each homeserver in priority order
for (const homeserver of homeserverPriority) {
try {
console.log(`Fetching well-known from ${homeserver}...`);
const wellKnown = await fetchWellKnownWithRTC(homeserver);
console.log(`Well-known response from ${homeserver}:`, wellKnown);
const livekitFocus = getLiveKitFocus(wellKnown);
console.log(`LiveKit focus from ${homeserver}:`, livekitFocus);
if (livekitFocus) {
console.log(`Call support found for room ${roomId} on homeserver: ${homeserver}`);
setRoomCallSupported(true);
setRoomCallSupportLoading(false);
return;
}
} catch (error) {
console.warn(`Failed to check call support on ${homeserver}:`, error);
// Continue to next homeserver
}
}
// No homeserver supports calls
console.log(`No call support found for room ${roomId}`);
setRoomCallSupported(false);
setRoomCallSupportLoading(false);
};
checkRoomCallSupport();
}, [roomId, mx]);
const isInCall = activeCall?.roomId === roomId; const isInCall = activeCall?.roomId === roomId;
const callState = isInCall ? activeCall.state : CallState.Idle; const callState = isInCall ? activeCall.state : CallState.Idle;
@@ -287,7 +327,7 @@ export function useRoomCall(roomId: string) {
startVoiceCall, startVoiceCall,
startVideoCall, startVideoCall,
endCall, endCall,
callSupported, callSupported: roomCallSupported,
callSupportLoading, callSupportLoading: roomCallSupportLoading,
}; };
} }

View File

@@ -10,7 +10,9 @@ import { getActiveCallMembers } from './useRoomCallMembers';
* Get room server from room ID (format: !roomid:server.com) * Get room server from room ID (format: !roomid:server.com)
*/ */
function getRoomServer(roomId: string): string | undefined { function getRoomServer(roomId: string): string | undefined {
return getMxIdServer(roomId); const server = getMxIdServer(roomId);
console.log(`[getRoomServer] Room ID: ${roomId} -> Server: ${server}`);
return server;
} }
/** /**
@@ -30,13 +32,20 @@ export function getLiveKitHomeserverPriority(
const memberCount = room.getMembers().length; const memberCount = room.getMembers().length;
const isDM = memberCount <= 2; const isDM = memberCount <= 2;
console.log(`[getLiveKitHomeserverPriority] Room: ${room.roomId}`);
console.log(`[getLiveKitHomeserverPriority] Member count: ${memberCount}, isDM: ${isDM}`);
console.log(`[getLiveKitHomeserverPriority] User homeserver: ${userHomeserver}`);
// Check for active call members // Check for active call members
const activeCallMembers = getActiveCallMembers(room); const activeCallMembers = getActiveCallMembers(room);
const hasActiveCall = activeCallMembers.length > 0; const hasActiveCall = activeCallMembers.length > 0;
console.log(`[getLiveKitHomeserverPriority] Active call members: ${activeCallMembers.length}, hasActiveCall: ${hasActiveCall}`);
if (hasActiveCall) { if (hasActiveCall) {
// Joining existing call - prioritize room's homeserver (where call is hosted) // Joining existing call - prioritize room's homeserver (where call is hosted)
const roomServer = getRoomServer(room.roomId); const roomServer = getRoomServer(room.roomId);
console.log(`[getLiveKitHomeserverPriority] Room server (active call): ${roomServer}`);
if (roomServer) { if (roomServer) {
servers.push(`https://${roomServer}`); servers.push(`https://${roomServer}`);
} }
@@ -60,32 +69,39 @@ export function getLiveKitHomeserverPriority(
} else { } else {
// Starting new call - use original priority logic // Starting new call - use original priority logic
if (isDM) { if (isDM) {
console.log(`[getLiveKitHomeserverPriority] DM path - user homeserver first`);
// For DMs: user's homeserver first, then other member's homeserver // For DMs: user's homeserver first, then other member's homeserver
servers.push(userHomeserver); servers.push(userHomeserver);
const otherUserId = guessDmRoomUserId(room, myUserId); const otherUserId = guessDmRoomUserId(room, myUserId);
console.log(`[getLiveKitHomeserverPriority] Other user ID: ${otherUserId}`);
if (otherUserId && otherUserId !== myUserId) { if (otherUserId && otherUserId !== myUserId) {
const otherServer = getMxIdServer(otherUserId); const otherServer = getMxIdServer(otherUserId);
console.log(`[getLiveKitHomeserverPriority] Other user server: ${otherServer}`);
const userDomain = userHomeserver.replace('https://', '').replace('http://', ''); const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
if (otherServer && otherServer !== userDomain) { if (otherServer && otherServer !== userDomain) {
servers.push(`https://${otherServer}`); servers.push(`https://${otherServer}`);
} }
} }
} else { } else {
console.log(`[getLiveKitHomeserverPriority] Channel path - room homeserver first`);
// For channels: room's homeserver first, then user's homeserver // For channels: room's homeserver first, then user's homeserver
const roomServer = getRoomServer(room.roomId); const roomServer = getRoomServer(room.roomId);
console.log(`[getLiveKitHomeserverPriority] Room server from ID: ${roomServer}`);
if (roomServer) { if (roomServer) {
servers.push(`https://${roomServer}`); servers.push(`https://${roomServer}`);
} }
// Fallback to user's homeserver if different // Fallback to user's homeserver if different
const userDomain = userHomeserver.replace('https://', '').replace('http://', ''); const userDomain = userHomeserver.replace('https://', '').replace('http://', '');
console.log(`[getLiveKitHomeserverPriority] User domain: ${userDomain}, room server: ${roomServer}`);
if (!roomServer || roomServer !== userDomain) { if (!roomServer || roomServer !== userDomain) {
servers.push(userHomeserver); servers.push(userHomeserver);
} }
} }
} }
console.log(`[getLiveKitHomeserverPriority] Final server list:`, servers);
return servers; return servers;
} }
@@ -96,11 +112,16 @@ export function getLiveKitHomeserverPriority(
*/ */
export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined { export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | undefined {
const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci']; const rtcFoci = wellKnown['org.matrix.msc4143.rtc_foci'];
console.log(`[getLiveKitFocus] rtcFoci from well-known:`, rtcFoci);
if (!rtcFoci || !Array.isArray(rtcFoci)) { if (!rtcFoci || !Array.isArray(rtcFoci)) {
console.log(`[getLiveKitFocus] No rtc_foci found or not an array`);
return undefined; return undefined;
} }
return rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit'); const livekitFocus = rtcFoci.find((focus): focus is LiveKitFocus => focus.type === 'livekit');
console.log(`[getLiveKitFocus] Found LiveKit focus:`, livekitFocus);
return livekitFocus;
} }
/** /**
@@ -109,14 +130,28 @@ export function getLiveKitFocus(wellKnown: WellKnownWithRTC): LiveKitFocus | und
* @returns The well-known response with RTC configuration * @returns The well-known response with RTC configuration
*/ */
export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> { export async function fetchWellKnownWithRTC(baseUrl: string): Promise<WellKnownWithRTC> {
const wellKnownUrl = `${baseUrl}/.well-known/matrix/client`; // In development mode (localhost), use CORS proxy to avoid CORS issues
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let wellKnownUrl: string;
if (isDev) {
// Extract hostname from baseUrl for proxy
const hostname = baseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
wellKnownUrl = `/_matrix-cors-proxy/${hostname}/.well-known/matrix/client`;
} else {
wellKnownUrl = `${baseUrl}/.well-known/matrix/client`;
}
console.log(`[fetchWellKnownWithRTC] Fetching from: ${wellKnownUrl}`);
const response = await fetch(wellKnownUrl); const response = await fetch(wellKnownUrl);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch well-known: ${response.status}`); throw new Error(`Failed to fetch well-known: ${response.status}`);
} }
return response.json(); const data = await response.json();
console.log(`[fetchWellKnownWithRTC] Response data:`, data);
return data;
} }
/** /**
@@ -132,7 +167,16 @@ export async function requestOpenIdToken(
userId: string, userId: string,
accessToken: string accessToken: string
): Promise<OpenIdToken> { ): Promise<OpenIdToken> {
const url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`; // In development mode (localhost), use CORS proxy to avoid CORS issues
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let url: string;
if (isDev) {
const hostname = homeserverBaseUrl.replace('https://', '').replace('http://', '').replace(/\/$/, '');
url = `/_matrix-cors-proxy/${hostname}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
} else {
url = `${homeserverBaseUrl}/_matrix/client/v3/user/${encodeURIComponent(userId)}/openid/request_token`;
}
const response = await fetch(url, { const response = await fetch(url, {
method: 'POST', method: 'POST',
@@ -175,7 +219,21 @@ export async function fetchLiveKitJWT(
// to create the LiveKit room name. // to create the LiveKit room name.
const slotId = 'm.call'; const slotId = 'm.call';
const response = await fetch(livekitServiceUrl, { // In development mode (localhost), use CORS proxy to avoid CORS issues
const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
let url: string;
if (isDev) {
// Extract hostname and path from livekitServiceUrl
const urlObj = new URL(livekitServiceUrl);
const hostname = urlObj.hostname;
const pathname = urlObj.pathname;
url = `/_matrix-cors-proxy/${hostname}${pathname}`;
} else {
url = livekitServiceUrl;
}
const response = await fetch(url, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -202,7 +260,8 @@ export async function fetchLiveKitJWT(
/** /**
* Try fetching LiveKit JWT from multiple homeservers in priority order * Try fetching LiveKit JWT from multiple homeservers in priority order
* @param homeservers - Array of homeserver base URLs to try * @param homeservers - Array of homeserver base URLs to check for LiveKit config
* @param userHomeserver - The user's own homeserver URL (for OpenID token)
* @param roomId - The Matrix room ID * @param roomId - The Matrix room ID
* @param userId - The Matrix user ID * @param userId - The Matrix user ID
* @param deviceId - The Matrix device ID * @param deviceId - The Matrix device ID
@@ -211,6 +270,7 @@ export async function fetchLiveKitJWT(
*/ */
export async function fetchLiveKitJWTFromServers( export async function fetchLiveKitJWTFromServers(
homeservers: string[], homeservers: string[],
userHomeserver: string,
roomId: string, roomId: string,
userId: string, userId: string,
deviceId: string, deviceId: string,
@@ -229,8 +289,9 @@ export async function fetchLiveKitJWTFromServers(
continue; continue;
} }
// Get OpenID token from this homeserver // Always get OpenID token from user's own homeserver
const openIdToken = await requestOpenIdToken(homeserver, userId, accessToken); // (Can't use access token on remote homeserver)
const openIdToken = await requestOpenIdToken(userHomeserver, userId, accessToken);
// Exchange for LiveKit JWT // Exchange for LiveKit JWT
const jwt = await fetchLiveKitJWT( const jwt = await fetchLiveKitJWT(

View File

@@ -56,7 +56,7 @@ import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { CallParticipantsIndicator } from './CallParticipantsIndicator'; import { CallParticipantsIndicator } from './CallParticipantsIndicator';
import { useCall } from '../call/useCall'; import { useCall, useRoomCall } from '../call/useCall';
import { CallType } from '../call/types'; import { CallType } from '../call/types';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
@@ -75,7 +75,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
const creators = useRoomCreators(room); const creators = useRoomCreators(room);
const { startCall, endCall, activeCall, callSupported } = useCall(); const { startCall: startCallGlobal, endCall, activeCall } = useCall();
const { callSupported } = useRoomCall(room.roomId);
const markAsRead = useMarkAsRead(mx); const markAsRead = useMarkAsRead(mx);
const permissions = useRoomPermissions(creators, powerLevels); const permissions = useRoomPermissions(creators, powerLevels);
@@ -119,7 +120,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
if (activeCall) { if (activeCall) {
await endCall(); await endCall();
} }
await startCall(room.roomId, CallType.Voice); await startCallGlobal(room.roomId, CallType.Voice);
requestClose(); requestClose();
} catch (error) { } catch (error) {
console.error('Failed to join voice call:', error); console.error('Failed to join voice call:', error);

View File

@@ -450,12 +450,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
{isConnecting ? ( {isConnecting ? (
<Spinner size="100" variant="Secondary" /> <Spinner size="100" variant="Secondary" />
) : ( ) : (
<Box direction="Row" gap="100" alignItems="Center"> <Icon size="400" src={Icons.Phone} filled={isActive && activeCall?.callType === CallType.Voice} />
<Icon size="400" src={Icons.Phone} filled={isActive && activeCall?.callType === CallType.Voice} />
{isUsingRemoteHomeserver && (
<Icon size="50" src={Icons.HashGlobe} style={{ opacity: 0.7 }} />
)}
</Box>
)} )}
</IconButton> </IconButton>
)} )}
@@ -481,12 +476,7 @@ function RoomCallButtons({ roomId }: RoomCallButtonsProps) {
{isConnecting ? ( {isConnecting ? (
<Spinner size="100" variant="Secondary" /> <Spinner size="100" variant="Secondary" />
) : ( ) : (
<Box direction="Row" gap="100" alignItems="Center"> <Icon size="400" src={Icons.VideoCamera} filled={isActive && activeCall?.callType === CallType.Video} />
<Icon size="400" src={Icons.VideoCamera} filled={isActive && activeCall?.callType === CallType.Video} />
{isUsingRemoteHomeserver && (
<Icon size="50" src={Icons.HashGlobe} style={{ opacity: 0.7 }} />
)}
</Box>
)} )}
</IconButton> </IconButton>
)} )}

View File

@@ -23,7 +23,7 @@ const DOMAIN_REGEX = /\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b/;
export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName); export const isServerName = (serverName: string): boolean => DOMAIN_REGEX.test(serverName);
const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@$+#])([^\s:]+):(\S+)$/); const matchMxId = (id: string): RegExpMatchArray | null => id.match(/^([@!$+#])([^\s:]+):(\S+)$/);
const validMxId = (id: string): boolean => !!matchMxId(id); const validMxId = (id: string): boolean => !!matchMxId(id);

View File

@@ -135,6 +135,83 @@ function servePublicAssets() {
}; };
} }
// CORS proxy for Matrix homeservers during development
function corsProxyMiddleware() {
return {
name: 'vite-plugin-cors-proxy',
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
// Proxy /.well-known requests: /_matrix-cors-proxy/homeserver.com/.well-known/matrix/client
const corsProxyMatch = req.url?.match(/^\/_matrix-cors-proxy\/([^/]+)(\/.*)/);
if (corsProxyMatch) {
const [, hostname, pathname] = corsProxyMatch;
const targetUrl = `https://${hostname}${pathname}`;
console.log(`[CORS Proxy] ${req.method} ${req.url}`);
console.log(`[CORS Proxy] Target: ${targetUrl}`);
// Handle preflight OPTIONS request
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.writeHead(204);
res.end();
return;
}
try {
// Collect request body for POST/PUT
let body = null;
if (req.method === 'POST' || req.method === 'PUT') {
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
body = Buffer.concat(chunks).toString();
console.log(`[CORS Proxy] Request body:`, body);
}
// Forward request with method, headers, and body
const fetchOptions = {
method: req.method,
headers: {
'Content-Type': req.headers['content-type'] || 'application/json',
'Authorization': req.headers['authorization'] || '',
},
};
if (body) {
fetchOptions.body = body;
}
const response = await fetch(targetUrl, fetchOptions);
const data = await response.text();
console.log(`[CORS Proxy] Response status: ${response.status}`);
console.log(`[CORS Proxy] Response data:`, data.substring(0, 500));
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
res.setHeader('Content-Type', response.headers.get('content-type') || 'application/json');
res.writeHead(response.status);
res.end(data);
} catch (error) {
console.error(`[CORS Proxy] Error for ${targetUrl}:`, error);
res.writeHead(500);
res.end(JSON.stringify({ error: error.message }));
}
} else {
next();
}
});
},
};
}
export default defineConfig({ export default defineConfig({
appType: 'spa', appType: 'spa',
publicDir: false, publicDir: false,
@@ -143,12 +220,14 @@ export default defineConfig({
server: { server: {
port: 38347, port: 38347,
host: '0.0.0.0', host: '0.0.0.0',
cors: true, // Enable CORS for dev server
fs: { fs: {
// Allow serving files from one level up to the project root // Allow serving files from one level up to the project root
allow: ['..'], allow: ['..'],
}, },
}, },
plugins: [ plugins: [
corsProxyMiddleware(), // Add CORS proxy for development
serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'), serverMatrixSdkCryptoWasm('/node_modules/.vite/deps/pkg/matrix_sdk_crypto_wasm_bg.wasm'),
serveGifWorker(), serveGifWorker(),
servePublicAssets(), servePublicAssets(),