diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index 0fcd86a..182c7b0 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -21,6 +21,9 @@ jobs: VITE_FIREBASE_MESSAGING_SENDER_ID: "${{ vars.VITE_FIREBASE_MESSAGING_SENDER_ID }}" VITE_FIREBASE_APP_ID: "${{ vars.VITE_FIREBASE_APP_ID }}" VITE_FIREBASE_MEASUREMENT_ID: "${{ vars.VITE_FIREBASE_MEASUREMENT_ID }}" + XIRSYS_API_IDENT: "${{ vars.XIRSYS_API_IDENT }}" + XIRSYS_API_CHANNEL: "${{ vars.XIRSYS_API_CHANNEL }}" + XIRSYS_API_SECRET: "${{ secrets.XIRSYS_API_SECRET }}" - uses: FirebaseExtended/action-hosting-deploy@v0 with: repoToken: "${{ secrets.GITHUB_TOKEN }}" diff --git a/.gitignore b/.gitignore index 35ea92c..6e4a769 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ coverage/ .env.development .env.development.local .env.production +functions/.env diff --git a/README.md b/README.md index 1af2b6f..b30cf04 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,12 @@ First, setup up your cloud environment: - On Firebase Console, go to "Firestore Database" menu option, and create one for you (in "testing mode" for local tests). +To quickly generate the `.env.development.local` from the template: + +```sh +cp .env.development.sample .env.development.local +``` + Restart your development server so they can load. ### Testing @@ -83,7 +89,9 @@ npm run test:watch Happy coding! -## Setting up for deployment +## Setting up for remote deployment + +### The project itself The [official repository](https://github.com/MazuhSoftwares/octo-call/) should already have a working pipeline of continuous integration and delivery. But here are a few @@ -92,22 +100,64 @@ summarized instructions for custom deployments. It'll need Blaze plan active for the Firebase project. Just for testing, its free limit should be enough even being a billable plan. +Then proceed to the manual deploy using CLI: + ```sh npm ci firebase login npm run deploy ``` -It'll deploy the static client side code and bunch of serverless functions. +It'll deploy the static client side code, the storage access rules +and bunch of serverless functions. It needs a `.env.production` file in set too. -To achieve more stability for users in different network settings, -you'll also need to create ICE servers (STUN/TURN) using a proper CPaaS -that provides it like [Xirsys](https://global.xirsys.net/dashboard/services) -(it has a free plan for testing), [Twilio](https://www.twilio.com/docs/stun-turn) -or even [coTURN](https://github.com/coturn/coturn) (free source, but you'll -host it by yourself in your own infrastructure). +In other words, it's pretty much the same as setting up the project for +development, you'll even need those steps of enabling the storage and authentication. + +### ICE servers + +For demonstration of connecting peers in the same network, +there's no need to consider the scenarios below. + +But different networks often have some extra complexity, like hiding peers +behind [NAT](https://medium.com/@laurayu_653/nat-why-do-we-need-it-f0230bb7d06f). +Then there's a need of intermediate servers to fix it. +It will, of course, potentially imply in more costs. +So be aware of it, and only use it if you want to extend this +proof of concept availability to everyone over Internet. + +These intermediate servers are often "ICE servers", they are +the [STUN](https://bloggeek.me/webrtcglossary/stun/) +and [ICE](https://bloggeek.me/webrtcglossary/). In short words, +while STUN tries to open the way through NAT configurations, +there's a fallback plan like the ICE server that goes even beyond +and can relay the entire media flow. +Because of this level of responsibility, although it's possible to use +free ICE servers, it's recommendeded to contract STUN/TURN from paid +service providers, even if it's in their free trial plan. + +Currently, this project supports integration with [Xirsys](https://xirsys.com/) +because it has a free plan available to anyone. And all the work of retrieving the credentials are implemented in the cloud functions. + +For manual deployments, put your Xirsys credentials in a `functions/.env` file. +You can also generate it from the template (note that +it's a different file from the core env vars): + +```sh +cp functions/.env.sample functions/.env +``` + +Remember to deploy the functions if you make changes to them. There's no +way to run them locally. So even for local tests, you need them deployed. +You can individually deploy only the serverless functions with +the `npm run deploy:functions` command. + +For custom forks of this project, it wouldn't be hard to use any other vendor. +The cloud function doing it is very simple and short, and can be easily changed. +Also, consider hosting the STUN/ICE in your own infrastructure +using the free source [coTURN](https://github.com/coturn/coturn) project. ## More non-functional requirements diff --git a/functions/.env.sample b/functions/.env.sample new file mode 100644 index 0000000..458f87a --- /dev/null +++ b/functions/.env.sample @@ -0,0 +1,3 @@ +XIRSYS_API_IDENT='' +XIRSYS_API_SECRET='' +XIRSYS_API_CHANNEL='' diff --git a/functions/src/index.ts b/functions/src/index.ts index ddc5345..4c5466e 100644 --- a/functions/src/index.ts +++ b/functions/src/index.ts @@ -1,19 +1,87 @@ -/** - * Import function triggers from their respective submodules: - * - * import {onCall} from "firebase-functions/v2/https"; - * import {onDocumentWritten} from "firebase-functions/v2/firestore"; - * - * See a full list of supported triggers at https://firebase.google.com/docs/functions - */ - -import { onRequest } from "firebase-functions/v2/https"; +import { HttpsError, onCall } from "firebase-functions/v2/https"; import * as logger from "firebase-functions/logger"; +import { defineString } from "firebase-functions/params"; -// Start writing functions -// https://firebase.google.com/docs/functions/typescript +export const getIceServersConfig = onCall( + async (request): Promise<{ iceServersConfig: RTCIceServer }> => { + if (!request.auth) { + throw new HttpsError( + "failed-precondition", + "No authentication detected." + ); + } -export const helloWorld = onRequest((request, response) => { - logger.info("Hello logs!", { structuredData: true }); - response.send("Hello from Firebase!"); -}); + const userEmail = request.auth.token.email; + if (!request.auth.token.email) { + throw new HttpsError("failed-precondition", "User with no email."); + } + + logger.info("Retrieving ICE servers for", userEmail); + + try { + const iceServersConfig = await fetchIceFromXirsys(); + return { iceServersConfig }; + } catch (error) { + logger.error( + "ICE provider error:", + (error as Error).message || "(no message)" + ); + throw new HttpsError( + "unavailable", + "No successful response from ICE server integration." + ); + } + } +); + +async function fetchIceFromXirsys(): Promise { + const ident: string = defineString("XIRSYS_API_IDENT", { + default: "", + description: "Xirsys API user identification for retrieving ICE servers.", + }).value(); + const secret: string = defineString("XIRSYS_API_SECRET", { + default: "", + description: "Xirsys API account secret for retrieving ICE servers.", + }).value(); + const channel: string = defineString("XIRSYS_API_CHANNEL", { + default: "", + description: "Xirsys API channel name for retrieving ICE servers.", + }).value(); + + if (!secret || !ident || !channel) { + throw new Error("Missing Xirsys API credentials."); + } + + const response = await fetch(`https://global.xirsys.net/_turn/${channel}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: + "Basic " + Buffer.from(`${ident}:${secret}`).toString("base64"), + }, + body: JSON.stringify({ + format: "urls", + }), + }); + const data = await response.json(); + + if (data.s !== "ok") { + throw new Error(data.v); + } + + if (!data.v.iceServers) { + // these "s" and "v" are weird, so lets just double check + throw new Error( + "Unknown content found in ICE server integration response." + ); + } + + if (Array.isArray(data.v.iceServers)) { + // the name is plural but it should not be an array + throw new Error( + "Unexpected array content found in ICE server integration response." + ); + } + + return data.v.iceServers; +} diff --git a/src/hooks/useP2PCall.ts b/src/hooks/useP2PCall.ts index 4dc9116..b5c8dac 100644 --- a/src/hooks/useP2PCall.ts +++ b/src/hooks/useP2PCall.ts @@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from "../state"; import { selectUserAudioId, selectUserVideoId } from "../state/devices"; import { patchP2PDescription, + selectIceServersConfig, selectP2PDescriptionByUidFn, } from "../state/call"; @@ -22,6 +23,8 @@ export default function useP2PCall(options: P2PCallHookOptions): void { selectP2PDescriptionByUidFn(options.p2pDescriptionUid) ); + const iceServersConfig = useAppSelector(selectIceServersConfig); + if (!description) { throw new Error( "Description not found for useP2PCall hook: " + p2pDescriptionUid @@ -42,6 +45,7 @@ export default function useP2PCall(options: P2PCallHookOptions): void { audio, video, isLocalPeerTheOfferingNewer, + iceServersConfig, outgoingSignaling: { onLocalJsepAction: async (localJsep) => { if (isLocalPeerTheOfferingNewer) { @@ -117,6 +121,7 @@ export default function useP2PCall(options: P2PCallHookOptions): void { p2pDescriptionUid, audio, video, + iceServersConfig, isLocalPeerTheOfferingNewer, localVideo, remoteVideo, diff --git a/src/services/firestore-signaling.ts b/src/services/firestore-signaling.ts index 0406092..1141883 100644 --- a/src/services/firestore-signaling.ts +++ b/src/services/firestore-signaling.ts @@ -12,6 +12,7 @@ import { writeBatch, } from "firebase/firestore"; import type { DocumentData, Unsubscribe } from "firebase/firestore"; +import { getFunctions, httpsCallable } from "firebase/functions"; import { v4 as uuidv4 } from "uuid"; import { db } from "./firestore-connection"; import type { @@ -33,6 +34,7 @@ const firestoreSignaling = { acceptPendingUser, rejectPendingUser, leaveCall, + getIceServersConfig, }; export default firestoreSignaling; @@ -251,3 +253,32 @@ export async function rejectPendingUser({ export async function leaveCall({ callUid, userUid }: CallUserExitIntent) { await deleteDoc(doc(db, `calls/${callUid}/users/${userUid}`)); } + +export async function getIceServersConfig(): Promise { + const functions = getFunctions(); + const getIceServer = httpsCallable( + functions, + "getIceServersConfig" + ); + + try { + const iceServersResult = await getIceServer(); + const iceServersConfig = iceServersResult.data.iceServersConfig; + if (!iceServersConfig) { + throw new Error( + "Bad response from cloud function while retrieving ICE servers." + ); + } + return iceServersConfig; + } catch (error) { + console.error( + "Failed to get ICE servers, then putting some default.", + error + ); + return DEFAULT_ICE_SERVERS_CONFIG; + } +} + +const DEFAULT_ICE_SERVERS_CONFIG: RTCIceServer = { + urls: ["stun:stun.l.google.com:19302", "stun:stun2.l.google.com:19302"], +}; diff --git a/src/state/call.ts b/src/state/call.ts index a317d6b..bd2f507 100644 --- a/src/state/call.ts +++ b/src/state/call.ts @@ -26,6 +26,7 @@ export interface CallState extends Call { pendingUsers: CallUser[]; p2pDescriptions: CallP2PDescription[]; errorMessage: string; + iceServersConfig?: RTCIceServer; } export const callInitialState: CallState = { @@ -53,6 +54,9 @@ export const callSlice = createSlice({ ) => { state.p2pDescriptions = action.payload; }, + setIceServersConfig: (state, action: PayloadAction) => { + state.iceServersConfig = action.payload; + }, }, extraReducers: (builder) => { builder.addCase(createCall.pending, (state, action) => { @@ -165,7 +169,13 @@ export const patchP2PDescription = createAsyncThunk( export const createCall = createAsyncThunk( "create-call", async ({ displayName }: Pick, thunkAPI) => { - const user = (thunkAPI.getState() as RootState).user; + const { user, call } = thunkAPI.getState() as RootState; + + if (!call.iceServersConfig) { + console.log("Retrieving ICE servers."); + const retrieved = await firestoreSignaling.getIceServersConfig(); + thunkAPI.dispatch(callSlice.actions.setIceServersConfig(retrieved)); + } return firestoreSignaling.createCall({ displayName, @@ -214,6 +224,11 @@ export const setCallUsers = createAsyncThunk( (u) => u.uid === user.uid ); if (call.userStatus === "pending-user" && isAmongParticipants) { + if (!call.iceServersConfig) { + const retrieved = await firestoreSignaling.getIceServersConfig(); + thunkApi.dispatch(callSlice.actions.setIceServersConfig(retrieved)); + } + await firestoreSignaling.joinAsNewerParticipation({ callUid: call.uid, userUid: user.uid, @@ -292,4 +307,7 @@ export const selectP2PDescriptionByUidFn = (descriptionUid: string) => (state: RootState) => state.call.p2pDescriptions.find((it) => it.uid === descriptionUid); +export const selectIceServersConfig = (state: RootState) => + state.call.iceServersConfig; + export default callSlice.reducer; diff --git a/src/webrtc/p2p-call-connection.ts b/src/webrtc/p2p-call-connection.ts index 527ea3e..453cc0e 100644 --- a/src/webrtc/p2p-call-connection.ts +++ b/src/webrtc/p2p-call-connection.ts @@ -63,6 +63,7 @@ export interface P2PCallConnectionOptions { video: string | boolean; isLocalPeerTheOfferingNewer: boolean; outgoingSignaling: P2PCallOutgoingSignaling; + iceServersConfig?: RTCIceServer; onLocalStream?: StreamListener; onRemoteStream?: StreamListener; } @@ -70,7 +71,9 @@ export interface P2PCallConnectionOptions { export function makeP2PCallConnection( options: P2PCallConnectionOptions ): P2PCallConnection { - const connection = new RTCPeerConnection(); + const connection = new RTCPeerConnection({ + iceServers: options.iceServersConfig ? [options.iceServersConfig] : [], + }); let startingBegun = false; let startingFinished = false;