Skip to content

Commit

Permalink
create/join will retrieve ice config from serverless functions
Browse files Browse the repository at this point in the history
  • Loading branch information
Mazuh committed Oct 26, 2023
1 parent 8ad9fb3 commit 662090e
Show file tree
Hide file tree
Showing 9 changed files with 208 additions and 26 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/firebase-hosting-merge.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}"
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,4 @@ coverage/
.env.development
.env.development.local
.env.production
functions/.env
66 changes: 58 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
3 changes: 3 additions & 0 deletions functions/.env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
XIRSYS_API_IDENT=''
XIRSYS_API_SECRET=''
XIRSYS_API_CHANNEL=''
100 changes: 84 additions & 16 deletions functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -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<RTCIceServer> {
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;
}
5 changes: 5 additions & 0 deletions src/hooks/useP2PCall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useAppDispatch, useAppSelector } from "../state";
import { selectUserAudioId, selectUserVideoId } from "../state/devices";
import {
patchP2PDescription,
selectIceServersConfig,
selectP2PDescriptionByUidFn,
} from "../state/call";

Expand All @@ -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
Expand All @@ -42,6 +45,7 @@ export default function useP2PCall(options: P2PCallHookOptions): void {
audio,
video,
isLocalPeerTheOfferingNewer,
iceServersConfig,
outgoingSignaling: {
onLocalJsepAction: async (localJsep) => {
if (isLocalPeerTheOfferingNewer) {
Expand Down Expand Up @@ -117,6 +121,7 @@ export default function useP2PCall(options: P2PCallHookOptions): void {
p2pDescriptionUid,
audio,
video,
iceServersConfig,
isLocalPeerTheOfferingNewer,
localVideo,
remoteVideo,
Expand Down
31 changes: 31 additions & 0 deletions src/services/firestore-signaling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -33,6 +34,7 @@ const firestoreSignaling = {
acceptPendingUser,
rejectPendingUser,
leaveCall,
getIceServersConfig,
};

export default firestoreSignaling;
Expand Down Expand Up @@ -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<RTCIceServer> {
const functions = getFunctions();
const getIceServer = httpsCallable<never, { iceServersConfig: RTCIceServer }>(
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"],
};
20 changes: 19 additions & 1 deletion src/state/call.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export interface CallState extends Call {
pendingUsers: CallUser[];
p2pDescriptions: CallP2PDescription[];
errorMessage: string;
iceServersConfig?: RTCIceServer;
}

export const callInitialState: CallState = {
Expand Down Expand Up @@ -53,6 +54,9 @@ export const callSlice = createSlice({
) => {
state.p2pDescriptions = action.payload;
},
setIceServersConfig: (state, action: PayloadAction<RTCIceServer>) => {
state.iceServersConfig = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(createCall.pending, (state, action) => {
Expand Down Expand Up @@ -165,7 +169,13 @@ export const patchP2PDescription = createAsyncThunk(
export const createCall = createAsyncThunk(
"create-call",
async ({ displayName }: Pick<Call, "displayName">, 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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
5 changes: 4 additions & 1 deletion src/webrtc/p2p-call-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,17 @@ export interface P2PCallConnectionOptions {
video: string | boolean;
isLocalPeerTheOfferingNewer: boolean;
outgoingSignaling: P2PCallOutgoingSignaling;
iceServersConfig?: RTCIceServer;
onLocalStream?: StreamListener;
onRemoteStream?: StreamListener;
}

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;
Expand Down

0 comments on commit 662090e

Please sign in to comment.