From b2016a5f6c0b54eeb1f24470d37f366693d5809e Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Sat, 2 Sep 2023 17:34:38 +0200 Subject: [PATCH 01/11] feat: emit error on connections when receiving "EXPIRE" Closes #924 --- lib/enums.ts | 1 + lib/peer.ts | 11 +++++++++++ 2 files changed, 12 insertions(+) diff --git a/lib/enums.ts b/lib/enums.ts index 0394f90aa..ee19d0cec 100644 --- a/lib/enums.ts +++ b/lib/enums.ts @@ -61,6 +61,7 @@ export enum PeerErrorType { } export enum BaseConnectionErrorType { + PeerUnavailable = "peer-unavailable", NegotiationFailed = "negotiation-failed", ConnectionClosed = "connection-closed", } diff --git a/lib/peer.ts b/lib/peer.ts index 6fa306cbc..4a77ba035 100644 --- a/lib/peer.ts +++ b/lib/peer.ts @@ -4,6 +4,7 @@ import { Socket } from "./socket"; import { MediaConnection } from "./mediaconnection"; import type { DataConnection } from "./dataconnection/DataConnection"; import { + BaseConnectionErrorType, ConnectionType, PeerErrorType, ServerMessageType, @@ -379,6 +380,16 @@ export class Peer extends EventEmitterWithError { PeerErrorType.PeerUnavailable, `Could not connect to peer ${peerId}`, ); + // Emit an error on all connections with this peer. + const connections = (this._connections.get(peerId) ?? []).filter( + (c) => c.peer === peerId, + ); + for (const conn of connections) { + conn.emitError( + BaseConnectionErrorType.PeerUnavailable, + `${peerId} is unavailable`, + ); + } break; case ServerMessageType.Offer: { // we should consider switching this to CALL/CONNECT, but this is the least breaking option. From 7e18b4451bef3b7b124b22d33bfbd43d7826443c Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Sat, 26 Aug 2023 23:04:19 +0200 Subject: [PATCH 02/11] test(e2e): "peer-unavailable" --- e2e/peer/peer-unavailable.html | 43 ++++++++++++++++++++++++++++++++++ e2e/peer/peer.spec.ts | 5 ++++ 2 files changed, 48 insertions(+) create mode 100644 e2e/peer/peer-unavailable.html diff --git a/e2e/peer/peer-unavailable.html b/e2e/peer/peer-unavailable.html new file mode 100644 index 000000000..cacf82296 --- /dev/null +++ b/e2e/peer/peer-unavailable.html @@ -0,0 +1,43 @@ + + + + + + + + + +

PEER-UNAVAILABLE

+
+
+ + + + diff --git a/e2e/peer/peer.spec.ts b/e2e/peer/peer.spec.ts index 7545a23ce..b593a3800 100644 --- a/e2e/peer/peer.spec.ts +++ b/e2e/peer/peer.spec.ts @@ -17,4 +17,9 @@ describe("Peer", () => { await P.waitForMessage('{"type":"disconnected"}'); expect(await P.errorMessage.getText()).toBe(""); }); + it("should emit an error, when the remote peer is unavailable", async () => { + await P.open("peer-unavailable"); + await P.waitForMessage('{"type":"peer-unavailable"}'); + expect(await P.errorMessage.getText()).toBe('{"type":"peer-unavailable"}'); + }); }); From 0319432d5d49b87d465f181c588219e542f189e8 Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Sat, 2 Sep 2023 18:16:51 +0200 Subject: [PATCH 03/11] docs: urge the user to listen to the `error` event --- lib/peer.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/peer.ts b/lib/peer.ts index 4a77ba035..2352ff43f 100644 --- a/lib/peer.ts +++ b/lib/peer.ts @@ -495,7 +495,11 @@ export class Peer extends EventEmitterWithError { /** * Connects to the remote peer specified by id and returns a data connection. - * @param peer The brokering ID of the remote peer (their {@apilink Peer.id}). + * + * Make sure to listen to the `error` event of the resulting {@link DataConnection} + * in case the connection fails. + * + * @param peer The brokering ID of the remote peer (their {@link Peer.id}). * @param options for specifying details about Peer Connection */ connect(peer: string, options: PeerConnectOption = {}): DataConnection { From 2cba46de28e3592471b1caa3932c9ef96e0d2b59 Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Mon, 28 Aug 2023 12:14:02 +0200 Subject: [PATCH 04/11] feat: `await new Peer()` --- e2e/peer/id-taken.await.html | 48 +++++++++++ e2e/peer/peer.spec.ts | 7 ++ lib/peer.ts | 150 ++++++++++++++++++++++------------- 3 files changed, 148 insertions(+), 57 deletions(-) create mode 100644 e2e/peer/id-taken.await.html diff --git a/e2e/peer/id-taken.await.html b/e2e/peer/id-taken.await.html new file mode 100644 index 000000000..5ff0297c5 --- /dev/null +++ b/e2e/peer/id-taken.await.html @@ -0,0 +1,48 @@ + + + + + + + + + +

ID-TAKEN

+
+
+ + + + diff --git a/e2e/peer/peer.spec.ts b/e2e/peer/peer.spec.ts index b593a3800..09d009749 100644 --- a/e2e/peer/peer.spec.ts +++ b/e2e/peer/peer.spec.ts @@ -23,3 +23,10 @@ describe("Peer", () => { expect(await P.errorMessage.getText()).toBe('{"type":"peer-unavailable"}'); }); }); +describe("Peer:async", () => { + it("should emit an error, when the ID is already taken", async () => { + await P.open("id-taken.await"); + await P.waitForMessage("No ID takeover"); + expect(await P.errorMessage.getText()).toBe(""); + }); +}); diff --git a/lib/peer.ts b/lib/peer.ts index 2352ff43f..dda52929a 100644 --- a/lib/peer.ts +++ b/lib/peer.ts @@ -108,10 +108,83 @@ export interface PeerEvents { */ error: (error: PeerError<`${PeerErrorType}`>) => void; } + +export interface IPeer { + /** + * The brokering ID of this peer + * + * If no ID was specified in {@apilink Peer | the constructor}, + * this will be `undefined` until the {@apilink PeerEvents | `open`} event is emitted. + */ + get id(): string; + get open(): boolean; + /** + * A hash of all connections associated with this peer, keyed by the remote peer's ID. + * @deprecated + * Return type will change from Object to Map + */ + get connections(): Object; + /** + * true if this peer and all of its connections can no longer be used. + */ + get destroyed(): boolean; + /** + * Connects to the remote peer specified by id and returns a data connection. + * + * Make sure to listen to the `error` event of the resulting {@link DataConnection} + * in case the connection fails. + * + * @param peer The brokering ID of the remote peer (their {@link Peer.id}). + * @param options for specifying details about Peer Connection + */ + connect(peer: string, options: PeerConnectOption): DataConnection; + /** + * Calls the remote peer specified by id and returns a media connection. + * @param peer The brokering ID of the remote peer (their peer.id). + * @param stream The caller's media stream + * @param options Metadata associated with the connection, passed in by whoever initiated the connection. + */ + call(peer: string, stream: MediaStream, options: CallOption): MediaConnection; + /** Retrieve a data/media connection for this peer. */ + getConnection( + peerId: string, + connectionId: string, + ): null | DataConnection | MediaConnection; + /** + * Destroys the Peer: closes all active connections as well as the connection + * to the server. + * + * :::caution + * This cannot be undone; the respective peer object will no longer be able + * to create or receive any connections, its ID will be forfeited on the server, + * and all of its data and media connections will be closed. + * ::: + */ + destroy(): void; + /** + * Disconnects the Peer's connection to the PeerServer. Does not close any + * active connections. + * Warning: The peer can no longer create or accept connections after being + * disconnected. It also cannot reconnect to the server. + */ + disconnect(): void; + /** Attempts to reconnect with the same ID. + * + * Only {@apilink Peer.disconnect | disconnected peers} can be reconnected. + * Destroyed peers cannot be reconnected. + * If the connection fails (as an example, if the peer's old ID is now taken), + * the peer's existing connections will not close, but any associated errors events will fire. + */ + reconnect(): void; +} + /** * A peer who can initiate connections with other peers. */ -export class Peer extends EventEmitterWithError { +export class Peer + extends EventEmitterWithError + implements IPeer +{ private static readonly DEFAULT_KEY = "peerjs"; protected readonly _serializers: SerializerMapping = { @@ -138,12 +211,11 @@ export class Peer extends EventEmitterWithError { (DataConnection | MediaConnection)[] > = new Map(); // All connections for this peer. private readonly _lostMessages: Map = new Map(); // src => [list of messages] - /** - * The brokering ID of this peer - * - * If no ID was specified in {@apilink Peer | the constructor}, - * this will be `undefined` until the {@apilink PeerEvents | `open`} event is emitted. - */ + private then: ( + onfulfilled?: (value: IPeer) => any, + onrejected?: (reason: PeerError) => any, + ) => void; + get id() { return this._id; } @@ -163,11 +235,6 @@ export class Peer extends EventEmitterWithError { return this._socket; } - /** - * A hash of all connections associated with this peer, keyed by the remote peer's ID. - * @deprecated - * Return type will change from Object to Map - */ get connections(): Object { const plainConnections = Object.create(null); @@ -178,15 +245,9 @@ export class Peer extends EventEmitterWithError { return plainConnections; } - /** - * true if this peer and all of its connections can no longer be used. - */ get destroyed() { return this._destroyed; } - /** - * false if there is an active connection to the PeerServer. - */ get disconnected() { return this._disconnected; } @@ -214,6 +275,20 @@ export class Peer extends EventEmitterWithError { constructor(id?: string | PeerOptions, options?: PeerOptions) { super(); + this.then = ( + onfulfilled?: (value: IPeer) => any, + onrejected?: (reason: PeerError) => any, + ) => { + // Remove 'then' to prevent potential recursion issues + // `await` will wait for a Promise-like to resolve recursively + delete this.then; + + // We don’t need to worry about cleaning up listeners here + // `await`ing a Promise will make sure only one of the paths executes + this.once("open", () => onfulfilled(this)); + this.once("error", onrejected); + }; + let userId: string | undefined; // Deal with overloading @@ -493,15 +568,6 @@ export class Peer extends EventEmitterWithError { return []; } - /** - * Connects to the remote peer specified by id and returns a data connection. - * - * Make sure to listen to the `error` event of the resulting {@link DataConnection} - * in case the connection fails. - * - * @param peer The brokering ID of the remote peer (their {@link Peer.id}). - * @param options for specifying details about Peer Connection - */ connect(peer: string, options: PeerConnectOption = {}): DataConnection { options = { serialization: "default", @@ -530,12 +596,6 @@ export class Peer extends EventEmitterWithError { return dataConnection; } - /** - * Calls the remote peer specified by id and returns a media connection. - * @param peer The brokering ID of the remote peer (their peer.id). - * @param stream The caller's media stream - * @param options Metadata associated with the connection, passed in by whoever initiated the connection. - */ call( peer: string, stream: MediaStream, @@ -600,7 +660,6 @@ export class Peer extends EventEmitterWithError { this._lostMessages.delete(connection.connectionId); } - /** Retrieve a data/media connection for this peer. */ getConnection( peerId: string, connectionId: string, @@ -642,16 +701,6 @@ export class Peer extends EventEmitterWithError { } } - /** - * Destroys the Peer: closes all active connections as well as the connection - * to the server. - * - * :::caution - * This cannot be undone; the respective peer object will no longer be able - * to create or receive any connections, its ID will be forfeited on the server, - * and all of its data and media connections will be closed. - * ::: - */ destroy(): void { if (this.destroyed) { return; @@ -688,12 +737,6 @@ export class Peer extends EventEmitterWithError { } } - /** - * Disconnects the Peer's connection to the PeerServer. Does not close any - * active connections. - * Warning: The peer can no longer create or accept connections after being - * disconnected. It also cannot reconnect to the server. - */ disconnect(): void { if (this.disconnected) { return; @@ -714,13 +757,6 @@ export class Peer extends EventEmitterWithError { this.emit("disconnected", currentId); } - /** Attempts to reconnect with the same ID. - * - * Only {@apilink Peer.disconnect | disconnected peers} can be reconnected. - * Destroyed peers cannot be reconnected. - * If the connection fails (as an example, if the peer's old ID is now taken), - * the peer's existing connections will not close, but any associated errors events will fire. - */ reconnect(): void { if (this.disconnected && !this.destroyed) { logger.log( From bee2bdbcfc4c11dfd41f8e6f10dd6d225d03f105 Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Sun, 3 Sep 2023 10:36:16 +0200 Subject: [PATCH 05/11] feat: `await peer.connect(...)` --- e2e/peer/id-taken.await.html | 2 +- e2e/peer/peer-unavailable.async.html | 41 ++++++++++++++++++++++++++++ e2e/peer/peer.spec.ts | 5 ++++ lib/dataconnection/DataConnection.ts | 29 ++++++++++++++++++-- 4 files changed, 74 insertions(+), 3 deletions(-) create mode 100644 e2e/peer/peer-unavailable.async.html diff --git a/e2e/peer/id-taken.await.html b/e2e/peer/id-taken.await.html index 5ff0297c5..0732419fc 100644 --- a/e2e/peer/id-taken.await.html +++ b/e2e/peer/id-taken.await.html @@ -14,7 +14,7 @@

ID-TAKEN

+ + + diff --git a/e2e/peer/peer.spec.ts b/e2e/peer/peer.spec.ts index 09d009749..570da5a81 100644 --- a/e2e/peer/peer.spec.ts +++ b/e2e/peer/peer.spec.ts @@ -29,4 +29,9 @@ describe("Peer:async", () => { await P.waitForMessage("No ID takeover"); expect(await P.errorMessage.getText()).toBe(""); }); + it("should emit an error, when the remote peer is unavailable", async () => { + await P.open("peer-unavailable.async"); + await P.waitForMessage("Success: Peer unavailable"); + expect(await P.errorMessage.getText()).toBe(""); + }); }); diff --git a/lib/dataconnection/DataConnection.ts b/lib/dataconnection/DataConnection.ts index 863a37598..262485862 100644 --- a/lib/dataconnection/DataConnection.ts +++ b/lib/dataconnection/DataConnection.ts @@ -11,6 +11,7 @@ import { BaseConnection, type BaseConnectionEvents } from "../baseconnection"; import type { ServerMessage } from "../servermessage"; import type { EventsWithError } from "../peerError"; import { randomToken } from "../utils/randomToken"; +import { PeerError } from "../peerError"; export interface DataConnectionEvents extends EventsWithError, @@ -25,6 +26,14 @@ export interface DataConnectionEvents open: () => void; } +export interface IDataConnection + extends BaseConnection { + /** Allows user to close connection. */ + close(options?: { flush?: boolean }): void; + /** Allows user to send data. */ + send(data: any, chunked?: boolean): void; +} + /** * Wraps a DataChannel between two Peers. */ @@ -38,6 +47,10 @@ export abstract class DataConnection extends BaseConnection< private _negotiator: Negotiator; abstract readonly serialization: string; readonly reliable: boolean; + private then: ( + onfulfilled?: (value: IDataConnection) => any, + onrejected?: (reason: PeerError) => any, + ) => void; public get type() { return ConnectionType.Data; @@ -46,6 +59,20 @@ export abstract class DataConnection extends BaseConnection< constructor(peerId: string, provider: Peer, options: any) { super(peerId, provider, options); + this.then = ( + onfulfilled?: (value: IDataConnection) => any, + onrejected?: (reason: PeerError) => any, + ) => { + // Remove 'then' to prevent potential recursion issues + // `await` will wait for a Promise-like to resolve recursively + delete this.then; + + // We don’t need to worry about cleaning up listeners here + // `await`ing a Promise will make sure only one of the paths executes + this.once("open", () => onfulfilled(this)); + this.once("error", onrejected); + }; + this.connectionId = this.options.connectionId || DataConnection.ID_PREFIX + randomToken(); @@ -87,7 +114,6 @@ export abstract class DataConnection extends BaseConnection< * Exposed functionality for users. */ - /** Allows user to close connection. */ close(options?: { flush?: boolean }): void { if (options?.flush) { this.send({ @@ -126,7 +152,6 @@ export abstract class DataConnection extends BaseConnection< protected abstract _send(data: any, chunked: boolean): void; - /** Allows user to send data. */ public send(data: any, chunked = false) { if (!this.open) { this.emitError( From e40007a698e1fc2eda9e78ff9252b669fc82c41c Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Sun, 3 Sep 2023 16:31:52 +0200 Subject: [PATCH 06/11] some `IPeer` types --- lib/peer.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/peer.ts b/lib/peer.ts index dda52929a..22fd5b744 100644 --- a/lib/peer.ts +++ b/lib/peer.ts @@ -109,7 +109,8 @@ export interface PeerEvents { error: (error: PeerError<`${PeerErrorType}`>) => void; } -export interface IPeer { +export interface IPeer + extends EventEmitterWithError { /** * The brokering ID of this peer * @@ -137,7 +138,7 @@ export interface IPeer { * @param peer The brokering ID of the remote peer (their {@link Peer.id}). * @param options for specifying details about Peer Connection */ - connect(peer: string, options: PeerConnectOption): DataConnection; + connect(peer: string, options?: PeerConnectOption): DataConnection; /** * Calls the remote peer specified by id and returns a media connection. * @param peer The brokering ID of the remote peer (their peer.id). From 54e2ac63cace07cb91496e4737e9f90e36772088 Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Tue, 5 Sep 2023 11:52:08 +0200 Subject: [PATCH 07/11] refactor: `implement Promise` for all PeerJS objects `Proxy` instead of patching `this` --- lib/baseconnection.ts | 58 ++++++++++++++------ lib/dataconnection/DataConnection.ts | 42 ++++++-------- lib/eventEmitterWithPromise.ts | 82 ++++++++++++++++++++++++++++ lib/mediaconnection.ts | 47 +++++++++------- lib/negotiator.ts | 7 ++- lib/peer.ts | 29 ++-------- lib/peerError.ts | 22 +------- tsconfig.json | 1 + 8 files changed, 182 insertions(+), 106 deletions(-) create mode 100644 lib/eventEmitterWithPromise.ts diff --git a/lib/baseconnection.ts b/lib/baseconnection.ts index 8c0c18402..bd0ab4fb1 100644 --- a/lib/baseconnection.ts +++ b/lib/baseconnection.ts @@ -2,16 +2,14 @@ import type { Peer } from "./peer"; import type { ServerMessage } from "./servermessage"; import type { ConnectionType } from "./enums"; import { BaseConnectionErrorType } from "./enums"; -import { - EventEmitterWithError, - type EventsWithError, - PeerError, -} from "./peerError"; +import { PeerError, type PromiseEvents } from "./peerError"; import type { ValidEventTypes } from "eventemitter3"; +import EventEmitter from "eventemitter3"; +import { EventEmitterWithPromise } from "./eventEmitterWithPromise"; export interface BaseConnectionEvents< ErrorType extends string = BaseConnectionErrorType, -> extends EventsWithError { +> extends PromiseEvents { /** * Emitted when either you or the remote peer closes the connection. * @@ -29,13 +27,44 @@ export interface BaseConnectionEvents< iceStateChanged: (state: RTCIceConnectionState) => void; } -export abstract class BaseConnection< +export interface IBaseConnection< SubClassEvents extends ValidEventTypes, ErrorType extends string = never, -> extends EventEmitterWithError< - ErrorType | BaseConnectionErrorType, - SubClassEvents & BaseConnectionEvents -> { +> extends EventEmitter< + | (SubClassEvents & + BaseConnectionEvents) + | BaseConnectionEvents + > { + readonly metadata: any; + readonly connectionId: string; + get type(): ConnectionType; + /** + * The optional label passed in or assigned by PeerJS when the connection was initiated. + */ + label: string; + /** + * Whether the media connection is active (e.g. your call has been answered). + * You can check this if you want to set a maximum wait time for a one-sided call. + */ + get open(): boolean; + close(): void; +} + +export abstract class BaseConnection< + AwaitType extends EventEmitter< + SubClassEvents & BaseConnectionEvents + >, + SubClassEvents extends ValidEventTypes, + ErrorType extends string = never, + > + extends EventEmitterWithPromise< + AwaitType, + never, + ErrorType | BaseConnectionErrorType, + SubClassEvents & BaseConnectionEvents + > + implements IBaseConnection +{ protected _open = false; /** @@ -50,15 +79,8 @@ export abstract class BaseConnection< abstract get type(): ConnectionType; - /** - * The optional label passed in or assigned by PeerJS when the connection was initiated. - */ label: string; - /** - * Whether the media connection is active (e.g. your call has been answered). - * You can check this if you want to set a maximum wait time for a one-sided call. - */ get open() { return this._open; } diff --git a/lib/dataconnection/DataConnection.ts b/lib/dataconnection/DataConnection.ts index 262485862..0c8e8e028 100644 --- a/lib/dataconnection/DataConnection.ts +++ b/lib/dataconnection/DataConnection.ts @@ -7,14 +7,20 @@ import { ServerMessageType, } from "../enums"; import type { Peer } from "../peer"; -import { BaseConnection, type BaseConnectionEvents } from "../baseconnection"; +import { + BaseConnection, + type BaseConnectionEvents, + IBaseConnection, +} from "../baseconnection"; import type { ServerMessage } from "../servermessage"; -import type { EventsWithError } from "../peerError"; +import type { PromiseEvents } from "../peerError"; import { randomToken } from "../utils/randomToken"; -import { PeerError } from "../peerError"; export interface DataConnectionEvents - extends EventsWithError, + extends PromiseEvents< + never, + DataConnectionErrorType | BaseConnectionErrorType + >, BaseConnectionEvents { /** * Emitted when data is received from the remote peer. @@ -27,7 +33,8 @@ export interface DataConnectionEvents } export interface IDataConnection - extends BaseConnection { + extends IBaseConnection { + get type(): ConnectionType.Data; /** Allows user to close connection. */ close(options?: { flush?: boolean }): void; /** Allows user to send data. */ @@ -38,19 +45,20 @@ export interface IDataConnection * Wraps a DataChannel between two Peers. */ export abstract class DataConnection extends BaseConnection< + IDataConnection, DataConnectionEvents, DataConnectionErrorType > { protected static readonly ID_PREFIX = "dc_"; protected static readonly MAX_BUFFERED_AMOUNT = 8 * 1024 * 1024; - private _negotiator: Negotiator; + private _negotiator: Negotiator< + DataConnectionEvents, + DataConnectionErrorType, + this + >; abstract readonly serialization: string; readonly reliable: boolean; - private then: ( - onfulfilled?: (value: IDataConnection) => any, - onrejected?: (reason: PeerError) => any, - ) => void; public get type() { return ConnectionType.Data; @@ -59,20 +67,6 @@ export abstract class DataConnection extends BaseConnection< constructor(peerId: string, provider: Peer, options: any) { super(peerId, provider, options); - this.then = ( - onfulfilled?: (value: IDataConnection) => any, - onrejected?: (reason: PeerError) => any, - ) => { - // Remove 'then' to prevent potential recursion issues - // `await` will wait for a Promise-like to resolve recursively - delete this.then; - - // We don’t need to worry about cleaning up listeners here - // `await`ing a Promise will make sure only one of the paths executes - this.once("open", () => onfulfilled(this)); - this.once("error", onrejected); - }; - this.connectionId = this.options.connectionId || DataConnection.ID_PREFIX + randomToken(); diff --git a/lib/eventEmitterWithPromise.ts b/lib/eventEmitterWithPromise.ts new file mode 100644 index 000000000..580dba316 --- /dev/null +++ b/lib/eventEmitterWithPromise.ts @@ -0,0 +1,82 @@ +import EventEmitter from "eventemitter3"; +import logger from "./logger"; +import { PeerError, PromiseEvents } from "./peerError"; + +export class EventEmitterWithPromise< + AwaitType extends EventEmitter, + OpenType, + ErrorType extends string, + Events extends PromiseEvents, + > + extends EventEmitter, never> + implements Promise +{ + protected _open = false; + readonly [Symbol.toStringTag]: string; + + catch( + onrejected?: + | ((reason: PeerError<`${ErrorType}`>) => PromiseLike | TResult) + | undefined + | null, + ): Promise { + return this.then(undefined, onrejected); + } + + finally(onfinally?: (() => void) | undefined | null): Promise { + return this.then().finally(onfinally); + } + + then( + onfulfilled?: + | ((value: AwaitType) => PromiseLike | TResult1) + | undefined + | null, + onrejected?: + | ((reason: any) => PromiseLike | TResult2) + | undefined + | null, + ): Promise { + const p = new Promise((resolve, reject) => { + const onOpen = () => { + this.off("error", onError); + // Remove 'then' to prevent potential recursion issues + // `await` will wait for a Promise-like to resolve recursively + resolve?.(proxyWithoutThen(this)); + }; + const onError = (err: PeerError<`${ErrorType}`>) => { + this.removeListener("open", onOpen); + reject(err); + }; + if (this._open) { + onOpen(); + return; + } + this.once("open", onOpen); + this.once("error", onError); + }); + return p.then(onfulfilled, onrejected); + } + + /** + * Emits a typed error message. + * + * @internal + */ + emitError(type: ErrorType, err: string | Error): void { + logger.error("Error:", err); + + this.emit("error", new PeerError<`${ErrorType}`>(`${type}`, err)); + } +} + +function proxyWithoutThen(obj: T) { + return new Proxy(obj, { + get(target, p, receiver) { + if (p === "then") { + return undefined; + } + return Reflect.get(target, p, receiver); + }, + }); +} diff --git a/lib/mediaconnection.ts b/lib/mediaconnection.ts index a138a464c..a54c942a4 100644 --- a/lib/mediaconnection.ts +++ b/lib/mediaconnection.ts @@ -24,15 +24,39 @@ export interface MediaConnectionEvents extends BaseConnectionEvents { willCloseOnRemote: () => void; } +export interface IMediaConnection + extends BaseConnection { + get type(): ConnectionType.Media; + get localStream(): MediaStream; + get remoteStream(): MediaStream; + /** + * When receiving a {@apilink PeerEvents | `call`} event on a peer, you can call + * `answer` on the media connection provided by the callback to accept the call + * and optionally send your own media stream. + + * + * @param stream A WebRTC media stream. + * @param options + * @returns + */ + answer(stream?: MediaStream, options?: AnswerOption): void; + + /** + * Closes the media connection. + */ + close(): void; +} /** * Wraps WebRTC's media streams. * To get one, use {@apilink Peer.call} or listen for the {@apilink PeerEvents | `call`} event. */ -export class MediaConnection extends BaseConnection { +export class MediaConnection extends BaseConnection< + IMediaConnection, + MediaConnectionEvents +> { private static readonly ID_PREFIX = "mc_"; - readonly label: string; - private _negotiator: Negotiator; + private _negotiator: Negotiator; private _localStream: MediaStream; private _remoteStream: MediaStream; @@ -112,16 +136,6 @@ export class MediaConnection extends BaseConnection { } } - /** - * When receiving a {@apilink PeerEvents | `call`} event on a peer, you can call - * `answer` on the media connection provided by the callback to accept the call - * and optionally send your own media stream. - - * - * @param stream A WebRTC media stream. - * @param options - * @returns - */ answer(stream?: MediaStream, options: AnswerOption = {}): void { if (this._localStream) { logger.warn( @@ -150,13 +164,6 @@ export class MediaConnection extends BaseConnection { this._open = true; } - /** - * Exposed functionality for users. - */ - - /** - * Closes the media connection. - */ close(): void { if (this._negotiator) { this._negotiator.cleanup(); diff --git a/lib/negotiator.ts b/lib/negotiator.ts index 6f5f46215..b03b225ac 100644 --- a/lib/negotiator.ts +++ b/lib/negotiator.ts @@ -15,7 +15,12 @@ import type { ValidEventTypes } from "eventemitter3"; */ export class Negotiator< Events extends ValidEventTypes, - ConnectionType extends BaseConnection, + ErrorType extends string, + ConnectionType extends BaseConnection< + any, + Events | BaseConnectionEvents, + ErrorType + >, > { constructor(readonly connection: ConnectionType) {} diff --git a/lib/peer.ts b/lib/peer.ts index 22fd5b744..77b9847a0 100644 --- a/lib/peer.ts +++ b/lib/peer.ts @@ -21,7 +21,9 @@ import { BinaryPack } from "./dataconnection/BufferedConnection/BinaryPack"; import { Raw } from "./dataconnection/BufferedConnection/Raw"; import { Json } from "./dataconnection/BufferedConnection/Json"; -import { EventEmitterWithError, PeerError } from "./peerError"; +import { PeerError } from "./peerError"; +import { EventEmitterWithPromise } from "./eventEmitterWithPromise"; +import EventEmitter from "eventemitter3"; class PeerOptions implements PeerJSOption { /** @@ -109,8 +111,7 @@ export interface PeerEvents { error: (error: PeerError<`${PeerErrorType}`>) => void; } -export interface IPeer - extends EventEmitterWithError { +export interface IPeer extends EventEmitter { /** * The brokering ID of this peer * @@ -183,7 +184,7 @@ export interface IPeer * A peer who can initiate connections with other peers. */ export class Peer - extends EventEmitterWithError + extends EventEmitterWithPromise implements IPeer { private static readonly DEFAULT_KEY = "peerjs"; @@ -206,16 +207,11 @@ export class Peer // States. private _destroyed = false; // Connections have been killed private _disconnected = false; // Connection to PeerServer killed but P2P connections still active - private _open = false; // Sockets and such are not yet open. private readonly _connections: Map< string, (DataConnection | MediaConnection)[] > = new Map(); // All connections for this peer. private readonly _lostMessages: Map = new Map(); // src => [list of messages] - private then: ( - onfulfilled?: (value: IPeer) => any, - onrejected?: (reason: PeerError) => any, - ) => void; get id() { return this._id; @@ -276,20 +272,6 @@ export class Peer constructor(id?: string | PeerOptions, options?: PeerOptions) { super(); - this.then = ( - onfulfilled?: (value: IPeer) => any, - onrejected?: (reason: PeerError) => any, - ) => { - // Remove 'then' to prevent potential recursion issues - // `await` will wait for a Promise-like to resolve recursively - delete this.then; - - // We don’t need to worry about cleaning up listeners here - // `await`ing a Promise will make sure only one of the paths executes - this.once("open", () => onfulfilled(this)); - this.once("error", onrejected); - }; - let userId: string | undefined; // Deal with overloading @@ -594,6 +576,7 @@ export class Peer options, ); this._addConnection(peer, dataConnection); + return dataConnection; } diff --git a/lib/peerError.ts b/lib/peerError.ts index c174d0c1f..b86c0cc79 100644 --- a/lib/peerError.ts +++ b/lib/peerError.ts @@ -1,26 +1,8 @@ -import { EventEmitter } from "eventemitter3"; -import logger from "./logger"; - -export interface EventsWithError { +export interface PromiseEvents { + open: (open?: OpenType) => void; error: (error: PeerError<`${ErrorType}`>) => void; } -export class EventEmitterWithError< - ErrorType extends string, - Events extends EventsWithError, -> extends EventEmitter { - /** - * Emits a typed error message. - * - * @internal - */ - emitError(type: ErrorType, err: string | Error): void { - logger.error("Error:", err); - - // @ts-ignore - this.emit("error", new PeerError<`${ErrorType}`>(`${type}`, err)); - } -} /** * A PeerError is emitted whenever an error occurs. * It always has a `.type`, which can be used to identify the error. diff --git a/tsconfig.json b/tsconfig.json index 7fabb216b..78b252c1d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,7 @@ "compilerOptions": { "target": "es5", "module": "commonjs", + "esModuleInterop": true, "downlevelIteration": true, "noUnusedLocals": true, "noUnusedParameters": true, From 15311d994095ecf794d337b0c1d2c294ff5fba91 Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Thu, 7 Sep 2023 15:56:36 +0200 Subject: [PATCH 08/11] refactor: change `removeListener` to `off` for consitency --- lib/eventEmitterWithPromise.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/eventEmitterWithPromise.ts b/lib/eventEmitterWithPromise.ts index 580dba316..a39367752 100644 --- a/lib/eventEmitterWithPromise.ts +++ b/lib/eventEmitterWithPromise.ts @@ -45,7 +45,7 @@ export class EventEmitterWithPromise< resolve?.(proxyWithoutThen(this)); }; const onError = (err: PeerError<`${ErrorType}`>) => { - this.removeListener("open", onOpen); + this.off("open", onOpen); reject(err); }; if (this._open) { From b49ef4a9b29de6f5954c65eb6aa63cde7a70cbd6 Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Thu, 7 Sep 2023 17:59:08 +0200 Subject: [PATCH 09/11] refactor: fix event types for library consumers This requires some `// @ts-expect-error` until we find out how to tell the typescript compiler what events are available --- lib/baseconnection.ts | 19 ++++++++----------- lib/dataconnection/DataConnection.ts | 11 ++++------- lib/eventEmitterWithPromise.ts | 10 ++++++++-- lib/mediaconnection.ts | 12 ++++++++---- lib/peer.ts | 10 +++++++--- 5 files changed, 35 insertions(+), 27 deletions(-) diff --git a/lib/baseconnection.ts b/lib/baseconnection.ts index bd0ab4fb1..3e500d72b 100644 --- a/lib/baseconnection.ts +++ b/lib/baseconnection.ts @@ -3,7 +3,6 @@ import type { ServerMessage } from "./servermessage"; import type { ConnectionType } from "./enums"; import { BaseConnectionErrorType } from "./enums"; import { PeerError, type PromiseEvents } from "./peerError"; -import type { ValidEventTypes } from "eventemitter3"; import EventEmitter from "eventemitter3"; import { EventEmitterWithPromise } from "./eventEmitterWithPromise"; @@ -28,13 +27,11 @@ export interface BaseConnectionEvents< } export interface IBaseConnection< - SubClassEvents extends ValidEventTypes, + SubClassEvents extends BaseConnectionEvents< + BaseConnectionErrorType | ErrorType + >, ErrorType extends string = never, -> extends EventEmitter< - | (SubClassEvents & - BaseConnectionEvents) - | BaseConnectionEvents - > { +> extends EventEmitter { readonly metadata: any; readonly connectionId: string; get type(): ConnectionType; @@ -51,17 +48,17 @@ export interface IBaseConnection< } export abstract class BaseConnection< - AwaitType extends EventEmitter< - SubClassEvents & BaseConnectionEvents + AwaitType extends EventEmitter, + SubClassEvents extends BaseConnectionEvents< + BaseConnectionErrorType | ErrorType >, - SubClassEvents extends ValidEventTypes, ErrorType extends string = never, > extends EventEmitterWithPromise< AwaitType, never, ErrorType | BaseConnectionErrorType, - SubClassEvents & BaseConnectionEvents + SubClassEvents > implements IBaseConnection { diff --git a/lib/dataconnection/DataConnection.ts b/lib/dataconnection/DataConnection.ts index 0c8e8e028..780e009c4 100644 --- a/lib/dataconnection/DataConnection.ts +++ b/lib/dataconnection/DataConnection.ts @@ -13,15 +13,12 @@ import { IBaseConnection, } from "../baseconnection"; import type { ServerMessage } from "../servermessage"; -import type { PromiseEvents } from "../peerError"; import { randomToken } from "../utils/randomToken"; export interface DataConnectionEvents - extends PromiseEvents< - never, - DataConnectionErrorType | BaseConnectionErrorType - >, - BaseConnectionEvents { + extends BaseConnectionEvents< + DataConnectionErrorType | BaseConnectionErrorType + > { /** * Emitted when data is received from the remote peer. */ @@ -60,7 +57,7 @@ export abstract class DataConnection extends BaseConnection< abstract readonly serialization: string; readonly reliable: boolean; - public get type() { + public get type(): ConnectionType.Data { return ConnectionType.Data; } diff --git a/lib/eventEmitterWithPromise.ts b/lib/eventEmitterWithPromise.ts index a39367752..ce4cc66f6 100644 --- a/lib/eventEmitterWithPromise.ts +++ b/lib/eventEmitterWithPromise.ts @@ -8,7 +8,7 @@ export class EventEmitterWithPromise< ErrorType extends string, Events extends PromiseEvents, > - extends EventEmitter, never> + extends EventEmitter implements Promise { protected _open = false; @@ -39,12 +39,14 @@ export class EventEmitterWithPromise< ): Promise { const p = new Promise((resolve, reject) => { const onOpen = () => { + // @ts-expect-error this.off("error", onError); // Remove 'then' to prevent potential recursion issues // `await` will wait for a Promise-like to resolve recursively resolve?.(proxyWithoutThen(this)); }; const onError = (err: PeerError<`${ErrorType}`>) => { + // @ts-expect-error this.off("open", onOpen); reject(err); }; @@ -52,7 +54,10 @@ export class EventEmitterWithPromise< onOpen(); return; } + + // @ts-expect-error this.once("open", onOpen); + // @ts-expect-error this.once("error", onError); }); return p.then(onfulfilled, onrejected); @@ -66,11 +71,12 @@ export class EventEmitterWithPromise< emitError(type: ErrorType, err: string | Error): void { logger.error("Error:", err); + // @ts-expect-error this.emit("error", new PeerError<`${ErrorType}`>(`${type}`, err)); } } -function proxyWithoutThen(obj: T) { +function proxyWithoutThen(obj: T): Omit { return new Proxy(obj, { get(target, p, receiver) { if (p === "then") { diff --git a/lib/mediaconnection.ts b/lib/mediaconnection.ts index a54c942a4..7c318b4c3 100644 --- a/lib/mediaconnection.ts +++ b/lib/mediaconnection.ts @@ -3,11 +3,15 @@ import logger from "./logger"; import { Negotiator } from "./negotiator"; import { ConnectionType, ServerMessageType } from "./enums"; import type { Peer } from "./peer"; -import { BaseConnection, type BaseConnectionEvents } from "./baseconnection"; +import { + BaseConnection, + type BaseConnectionEvents, + IBaseConnection, +} from "./baseconnection"; import type { ServerMessage } from "./servermessage"; import type { AnswerOption } from "./optionInterfaces"; -export interface MediaConnectionEvents extends BaseConnectionEvents { +export interface MediaConnectionEvents extends BaseConnectionEvents { /** * Emitted when a connection to the PeerServer is established. * @@ -25,7 +29,7 @@ export interface MediaConnectionEvents extends BaseConnectionEvents { } export interface IMediaConnection - extends BaseConnection { + extends IBaseConnection { get type(): ConnectionType.Media; get localStream(): MediaStream; get remoteStream(): MediaStream; @@ -63,7 +67,7 @@ export class MediaConnection extends BaseConnection< /** * For media connections, this is always 'media'. */ - get type() { + get type(): ConnectionType.Media { return ConnectionType.Media; } diff --git a/lib/peer.ts b/lib/peer.ts index 77b9847a0..da0a81ce9 100644 --- a/lib/peer.ts +++ b/lib/peer.ts @@ -21,7 +21,7 @@ import { BinaryPack } from "./dataconnection/BufferedConnection/BinaryPack"; import { Raw } from "./dataconnection/BufferedConnection/Raw"; import { Json } from "./dataconnection/BufferedConnection/Json"; -import { PeerError } from "./peerError"; +import { PeerError, PromiseEvents } from "./peerError"; import { EventEmitterWithPromise } from "./eventEmitterWithPromise"; import EventEmitter from "eventemitter3"; @@ -80,7 +80,7 @@ export interface SerializerMapping { ) => DataConnection; } -export interface PeerEvents { +export interface PeerEvents extends PromiseEvents { /** * Emitted when a connection to the PeerServer is established. * @@ -146,7 +146,11 @@ export interface IPeer extends EventEmitter { * @param stream The caller's media stream * @param options Metadata associated with the connection, passed in by whoever initiated the connection. */ - call(peer: string, stream: MediaStream, options: CallOption): MediaConnection; + call( + peer: string, + stream: MediaStream, + options?: CallOption, + ): MediaConnection; /** Retrieve a data/media connection for this peer. */ getConnection( peerId: string, From 5edbfd5fac4f72a7c67ab4544cf1182f5632aa1f Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Thu, 7 Sep 2023 18:21:20 +0200 Subject: [PATCH 10/11] refactor: try to make `id-taken.await` clearer --- e2e/peer/id-taken.await.html | 36 ++++++++++++++++++------------------ e2e/peer/id-taken.html | 4 ++-- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/e2e/peer/id-taken.await.html b/e2e/peer/id-taken.await.html index 0732419fc..279bfc6d3 100644 --- a/e2e/peer/id-taken.await.html +++ b/e2e/peer/id-taken.await.html @@ -22,25 +22,25 @@

ID-TAKEN

const errorMessage = document.getElementById("error-message"); // Peer A should be created without an error - try { - const peerA = await new Peer(); - // Create 10 new `Peer`s that will try to steel A's id - let peers_try_to_take = Array.from({ length: 10 }, async (_, i) => { - try { - await new Peer(peerA.id); - throw `Peer ${i} failed! Connection got established.`; - } catch (error) { - if (error.type === "unavailable-id") { - return `ID already taken. (${i})`; - } else { - throw error; - } - } - }); - await Promise.all(peers_try_to_take); + const peerA = await new Peer().catch( + (error) => (errorMessage.textContent += JSON.stringify(error)), + ); + + // Create 10 new `Peer`s that will try to steel A's id + // Wait for all peers to finish + const steeling_peers = await Promise.allSettled( + Array.from({ length: 10 }, () => new Peer(peerA.id)), + ); + + if ( + steeling_peers.every( + ({ reason, status }) => + status === "rejected" && reason.type === "unavailable-id", + ) + ) { messages.textContent = "No ID takeover"; - } catch (error) { - errorMessage.textContent += JSON.stringify(error); + } else { + errorMessage.textContent += JSON.stringify(steeling_peers); } })(); diff --git a/e2e/peer/id-taken.html b/e2e/peer/id-taken.html index 411ac53fb..d4a4e67cf 100644 --- a/e2e/peer/id-taken.html +++ b/e2e/peer/id-taken.html @@ -28,7 +28,7 @@

ID-TAKEN

) .once("open", (id) => { // Create 10 new `Peer`s that will try to steel A's id - let peers_try_to_take = Array.from( + const steeling_peers = Array.from( { length: 10 }, (_, i) => new Promise((resolve, reject) => @@ -45,7 +45,7 @@

ID-TAKEN

}), ), ); - Promise.all(peers_try_to_take) + Promise.all(steeling_peers) .then(() => (messages.textContent = "No ID takeover")) .catch( (error) => (errorMessage.textContent += JSON.stringify(error)), From b26a8e7ff7152feb546612e55df3c2f623714620 Mon Sep 17 00:00:00 2001 From: Jonas Gloning <34194370+jonasgloning@users.noreply.github.com> Date: Thu, 7 Sep 2023 18:34:56 +0200 Subject: [PATCH 11/11] npm run check --- lib/negotiator.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/negotiator.ts b/lib/negotiator.ts index b03b225ac..a55af35b8 100644 --- a/lib/negotiator.ts +++ b/lib/negotiator.ts @@ -8,13 +8,12 @@ import { ServerMessageType, } from "./enums"; import type { BaseConnection, BaseConnectionEvents } from "./baseconnection"; -import type { ValidEventTypes } from "eventemitter3"; /** * Manages all negotiations between Peers. */ export class Negotiator< - Events extends ValidEventTypes, + Events extends BaseConnectionEvents, ErrorType extends string, ConnectionType extends BaseConnection< any,