All files / src/web rendezvous-client.js

100% Statements 29/29
100% Branches 17/17
100% Functions 11/11
100% Lines 25/25

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 724x 4x 4x       33x 33x 33x                         384x 384x 384x 384x             384x 384x 22x 22x         362x     384x   51x     50x       4x     50x       258x     8x       14x 14x 8x        
const DefaultBase = "/api/rendezvous";
const SecretHeader = "x-host-secret";
const AnswerPending = 202;
 
export class RendezvousError extends Error {
    constructor(message, status) {
        super(message);
        this.name = "RendezvousError";
        this.status = status;
    }
}
 
/**
 * The browser's side of the rendezvous API (rendezvous/handler.js).
 *
 * @param {object} [options]
 * @param {string} [options.base]
 * @param {typeof fetch} [options.fetch]
 */
export function createRendezvousClient({ base = DefaultBase, fetch: fetchFn = globalThis.fetch } = {}) {
    async function request(what, method, path, { secret, body, keepalive = false } = {}) {
        const headers = {};
        if (secret !== undefined) headers[SecretHeader] = secret;
        if (body !== undefined) headers["content-type"] = "application/json";
        const response = await fetchFn(`${base}${path}`, {
            method,
            headers,
            body: body === undefined ? undefined : JSON.stringify(body),
            cache: "no-store",
            keepalive,
        });
        const json = await response.json().catch(() => ({}));
        if (!response.ok) {
            const detail = json.error ? `: ${json.error}` : "";
            throw new RendezvousError(
                `Rendezvous ${what} failed with status ${response.status}${detail}`,
                response.status,
            );
        }
        return { status: response.status, json };
    }
 
    const roomPath = (room) => `/room/${encodeURIComponent(room)}`;
 
    return {
        /** @returns {Promise<string>} the host secret */
        async createRoom(room) {
            return (await request("create room", "POST", roomPath(room))).json.secret;
        },
        /** Survives the page unloading, so a host can call it as it goes. */
        async deleteRoom(room, secret) {
            await request("delete room", "DELETE", roomPath(room), { secret, keepalive: true });
        },
        async postOffer(room, guest, sdp) {
            await request("post offer", "POST", `${roomPath(room)}/offer`, { body: { guest, sdp } });
        },
        /** @returns {Promise<{guest: string, sdp: string}[]>} offers not yet answered */
        async listOffers(room, secret) {
            return (await request("list offers", "GET", `${roomPath(room)}/offers`, { secret })).json.offers;
        },
        async postAnswer(room, secret, guest, sdp) {
            await request("post answer", "POST", `${roomPath(room)}/answer`, { secret, body: { guest, sdp } });
        },
        /** @returns {Promise<string | null>} the answer's SDP, or null while the host has not answered */
        async getAnswer(room, guest) {
            const path = `${roomPath(room)}/answer/${encodeURIComponent(guest)}`;
            const { status, json } = await request("get answer", "GET", path);
            return status === AnswerPending ? null : json.sdp;
        },
    };
}