Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

create/join will retrieve ice config from serverless functions #46

Merged
merged 2 commits into from
Oct 26, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
67 changes: 59 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,65 @@ 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 jest-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,8 @@ import "@testing-library/jest-dom";
jest.mock("./src/services/firestore-connection.ts", () => ({
db: {},
}));

jest.mock("firebase/functions", () => ({
getFunctions: jest.fn().mockReturnValue({}),
httpsCallable: jest.fn().mockReturnValue({}),
}));
31 changes: 31 additions & 0 deletions src/features/call-selection/CallCreationMain.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe("CallCreationMain", () => {
beforeEach(() => {
(firestoreSignaling.createCall as jest.Mock).mockClear();
(firestoreSignaling.askToJoinCall as jest.Mock).mockClear();
(firestoreSignaling.getIceServersConfig as jest.Mock).mockClear();

(webrtc.retrieveMediaInputs as jest.Mock).mockResolvedValue([
{
Expand Down Expand Up @@ -65,6 +66,36 @@ describe("CallCreationMain", () => {
expect(firestoreSignaling.createCall).toBeCalledTimes(1);
});

it("call creation is integrated with firebase to also gather ice config", async () => {
await act(() =>
fullRender(<CallCreationMain />, {
preloadedState: {
user: {
...userInitialState,
uid: "1m2kkn3",
displayName: "John Doe",
status: "authenticated",
},
devices: {
...devicesInitialState,
userAudioId: "my-mic-42",
},
},
})
);

const callNameInputElement = screen.getByLabelText("Call public name:");

const callCreationButtonElement = screen.getByRole("button", {
name: "Create call",
});

fireEvent.change(callNameInputElement, { target: { value: "Daily" } });
await act(() => fireEvent.click(callCreationButtonElement));

expect(firestoreSignaling.getIceServersConfig).toBeCalledTimes(1);
});

it("can not create call if has no device", async () => {
await act(() =>
fullRender(<CallCreationMain />, {
Expand Down
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"],
};
Loading