diff --git a/e2e/peer/id-taken.await.html b/e2e/peer/id-taken.await.html new file mode 100644 index 000000000..279bfc6d3 --- /dev/null +++ b/e2e/peer/id-taken.await.html @@ -0,0 +1,48 @@ + + + + + + + + + +

ID-TAKEN

+
+
+ + + + 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)), diff --git a/e2e/peer/peer-unavailable.async.html b/e2e/peer/peer-unavailable.async.html new file mode 100644 index 000000000..7b4dc97e4 --- /dev/null +++ b/e2e/peer/peer-unavailable.async.html @@ -0,0 +1,41 @@ + + + + + + + + + +

PEER-UNAVAILABLE

+
+
+ + + + 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..570da5a81 100644 --- a/e2e/peer/peer.spec.ts +++ b/e2e/peer/peer.spec.ts @@ -17,4 +17,21 @@ 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"}'); + }); +}); +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(""); + }); + 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/baseconnection.ts b/lib/baseconnection.ts index 8c0c18402..3e500d72b 100644 --- a/lib/baseconnection.ts +++ b/lib/baseconnection.ts @@ -2,16 +2,13 @@ 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 type { ValidEventTypes } from "eventemitter3"; +import { PeerError, type PromiseEvents } from "./peerError"; +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 +26,42 @@ export interface BaseConnectionEvents< iceStateChanged: (state: RTCIceConnectionState) => void; } -export abstract class BaseConnection< - SubClassEvents extends ValidEventTypes, +export interface IBaseConnection< + SubClassEvents extends BaseConnectionEvents< + BaseConnectionErrorType | ErrorType + >, ErrorType extends string = never, -> extends EventEmitterWithError< - ErrorType | BaseConnectionErrorType, - SubClassEvents & BaseConnectionEvents -> { +> extends EventEmitter { + 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 extends BaseConnectionEvents< + BaseConnectionErrorType | ErrorType + >, + ErrorType extends string = never, + > + extends EventEmitterWithPromise< + AwaitType, + never, + ErrorType | BaseConnectionErrorType, + SubClassEvents + > + implements IBaseConnection +{ protected _open = false; /** @@ -50,15 +76,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 863a37598..780e009c4 100644 --- a/lib/dataconnection/DataConnection.ts +++ b/lib/dataconnection/DataConnection.ts @@ -7,14 +7,18 @@ 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 { randomToken } from "../utils/randomToken"; export interface DataConnectionEvents - extends EventsWithError, - BaseConnectionEvents { + extends BaseConnectionEvents< + DataConnectionErrorType | BaseConnectionErrorType + > { /** * Emitted when data is received from the remote peer. */ @@ -25,21 +29,35 @@ export interface DataConnectionEvents open: () => void; } +export interface IDataConnection + extends IBaseConnection { + get type(): ConnectionType.Data; + /** 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. */ 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; - public get type() { + public get type(): ConnectionType.Data { return ConnectionType.Data; } @@ -87,7 +105,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 +143,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( 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/eventEmitterWithPromise.ts b/lib/eventEmitterWithPromise.ts new file mode 100644 index 000000000..ce4cc66f6 --- /dev/null +++ b/lib/eventEmitterWithPromise.ts @@ -0,0 +1,88 @@ +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 + 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 = () => { + // @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); + }; + if (this._open) { + onOpen(); + return; + } + + // @ts-expect-error + this.once("open", onOpen); + // @ts-expect-error + 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); + + // @ts-expect-error + this.emit("error", new PeerError<`${ErrorType}`>(`${type}`, err)); + } +} + +function proxyWithoutThen(obj: T): Omit { + 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..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. * @@ -24,22 +28,46 @@ export interface MediaConnectionEvents extends BaseConnectionEvents { willCloseOnRemote: () => void; } +export interface IMediaConnection + extends IBaseConnection { + 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; /** * For media connections, this is always 'media'. */ - get type() { + get type(): ConnectionType.Media { return ConnectionType.Media; } @@ -112,16 +140,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 +168,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..a55af35b8 100644 --- a/lib/negotiator.ts +++ b/lib/negotiator.ts @@ -8,14 +8,18 @@ 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, - ConnectionType extends BaseConnection, + Events extends BaseConnectionEvents, + 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 6fa306cbc..da0a81ce9 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, @@ -20,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, PromiseEvents } from "./peerError"; +import { EventEmitterWithPromise } from "./eventEmitterWithPromise"; +import EventEmitter from "eventemitter3"; class PeerOptions implements PeerJSOption { /** @@ -77,7 +80,7 @@ export interface SerializerMapping { ) => DataConnection; } -export interface PeerEvents { +export interface PeerEvents extends PromiseEvents { /** * Emitted when a connection to the PeerServer is established. * @@ -107,10 +110,87 @@ export interface PeerEvents { */ error: (error: PeerError<`${PeerErrorType}`>) => void; } + +export interface IPeer extends EventEmitter { + /** + * 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 EventEmitterWithPromise + implements IPeer +{ private static readonly DEFAULT_KEY = "peerjs"; protected readonly _serializers: SerializerMapping = { @@ -131,18 +211,12 @@ export class Peer extends EventEmitterWithError { // 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] - /** - * 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() { return this._id; } @@ -162,11 +236,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); @@ -177,15 +246,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; } @@ -379,6 +442,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. @@ -482,11 +555,6 @@ export class Peer extends EventEmitterWithError { return []; } - /** - * 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}). - * @param options for specifying details about Peer Connection - */ connect(peer: string, options: PeerConnectOption = {}): DataConnection { options = { serialization: "default", @@ -512,15 +580,10 @@ export class Peer extends EventEmitterWithError { options, ); this._addConnection(peer, dataConnection); + 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, @@ -585,7 +648,6 @@ export class Peer extends EventEmitterWithError { this._lostMessages.delete(connection.connectionId); } - /** Retrieve a data/media connection for this peer. */ getConnection( peerId: string, connectionId: string, @@ -627,16 +689,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; @@ -673,12 +725,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; @@ -699,13 +745,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( 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,