diff --git a/src/internal/webrtc.ts b/src/internal/webrtc.ts new file mode 100644 index 00000000..c435b935 --- /dev/null +++ b/src/internal/webrtc.ts @@ -0,0 +1,207 @@ +import EventEmitter from 'eventemitter3' +import { Logger } from '../utils/logger' + +export const OPCODE = { + MOVE: 0x01, + SCROLL: 0x02, + KEY_DOWN: 0x03, + KEY_UP: 0x04, +} as const + +export interface NekoWebRTCEvents { + connecting: () => void + connected: () => void + disconnected: (error?: Error) => void + track: (event: RTCTrackEvent) => void +} + +export abstract class NekoWebRTC extends EventEmitter { + private _peer?: RTCPeerConnection + private _channel?: RTCDataChannel + private _state: RTCIceConnectionState = 'disconnected' + private _log: Logger + + constructor() { + super() + + this._log = new Logger('webrtc') + } + + get supported() { + return typeof RTCPeerConnection !== 'undefined' && typeof RTCPeerConnection.prototype.addTransceiver !== 'undefined' + } + + get connected() { + return typeof this._peer !== 'undefined' && ['connected', 'checking', 'completed'].includes(this._state) + } + + public async connect(sdp: string, lite: boolean, servers: string[]): Promise { + this._log.debug(`creating peer`) + + if (!this.supported) { + throw new Error('browser does not support webrtc') + } + + if (this.connected) { + throw new Error('attempting to create peer while connected') + } + + this.emit('connecting') + + this._peer = new RTCPeerConnection() + if (lite !== true) { + this._peer = new RTCPeerConnection({ + iceServers: [{ urls: servers }], + }) + } + + this._peer.onconnectionstatechange = (event) => { + this._log.debug(`peer connection state changed`, this._peer ? this._peer.connectionState : undefined) + } + + this._peer.onsignalingstatechange = (event) => { + this._log.debug(`peer signaling state changed`, this._peer ? this._peer.signalingState : undefined) + } + + this._peer.oniceconnectionstatechange = (event) => { + this._state = this._peer!.iceConnectionState + this._log.debug(`peer ice connection state changed: ${this._peer!.iceConnectionState}`) + + switch (this._state) { + case 'checking': + break + case 'connected': + this.onConnected() + break + case 'failed': + this.onDisconnected(new Error('peer failed')) + break + case 'disconnected': + this.onDisconnected(new Error('peer disconnected')) + break + } + } + + this._peer.ontrack = this.onTrack.bind(this) + this._peer.addTransceiver('audio', { direction: 'recvonly' }) + this._peer.addTransceiver('video', { direction: 'recvonly' }) + + this._channel = this._peer.createDataChannel('data') + this._channel.onerror = this.onError.bind(this) + this._channel.onmessage = this.onData.bind(this) + this._channel.onclose = this.onDisconnected.bind(this, new Error('peer data channel closed')) + + this._peer.setRemoteDescription({ type: 'offer', sdp }) + + let answer = await this._peer.createAnswer() + this._peer!.setLocalDescription(answer) + + if (!answer.sdp) { + throw new Error('sdp answer is empty') + } + + return answer.sdp + } + + public disconnect() { + if (this.connected) { + try { + this._peer!.close() + } catch (err) {} + + this._peer = undefined + this._channel = undefined + } + + this._state = 'disconnected' + } + + public send(event: 'wheel' | 'mousemove', data: { x: number; y: number }): void + public send(event: 'mousedown' | 'mouseup' | 'keydown' | 'keyup', data: { key: number }): void + public send(event: string, data: any): void { + if (!this.connected) { + this._log.warn(`attempting to send data while disconnected`) + return + } + + let buffer: ArrayBuffer + let payload: DataView + switch (event) { + case 'mousemove': + buffer = new ArrayBuffer(7) + payload = new DataView(buffer) + payload.setUint8(0, OPCODE.MOVE) + payload.setUint16(1, 4, true) + payload.setUint16(3, data.x, true) + payload.setUint16(5, data.y, true) + break + case 'wheel': + buffer = new ArrayBuffer(7) + payload = new DataView(buffer) + payload.setUint8(0, OPCODE.SCROLL) + payload.setUint16(1, 4, true) + payload.setInt16(3, data.x, true) + payload.setInt16(5, data.y, true) + break + case 'keydown': + case 'mousedown': + buffer = new ArrayBuffer(11) + payload = new DataView(buffer) + payload.setUint8(0, OPCODE.KEY_DOWN) + payload.setUint16(1, 8, true) + payload.setBigUint64(3, BigInt(data.key), true) + break + case 'keyup': + case 'mouseup': + buffer = new ArrayBuffer(11) + payload = new DataView(buffer) + payload.setUint8(0, OPCODE.KEY_UP) + payload.setUint16(1, 8, true) + payload.setBigUint64(3, BigInt(data.key), true) + break + default: + this._log.warn(`unknown data event: ${event}`) + } + + // @ts-ignore + if (typeof buffer !== 'undefined') { + this._channel!.send(buffer) + } + } + + // not-implemented + private onData(e: MessageEvent) {} + + private onTrack(event: RTCTrackEvent) { + this._log.debug(`received ${event.track.kind} track from peer: ${event.track.id}`, event) + const stream = event.streams[0] + + if (!stream) { + this._log.warn(`no stream provided for track ${event.track.id}(${event.track.label})`) + return + } + + this.emit('track', event) + } + + private onError(event: Event) { + this._log.error((event as ErrorEvent).error) + } + + private onConnected() { + if (!this.connected) { + this._log.warn(`onConnected called while being disconnected`) + return + } + + this._log.debug(`connected`) + this.emit('connected') + } + + private onDisconnected(reason?: Error) { + this.disconnect() + + this._log.debug(`disconnected:`, reason) + this.emit('disconnected', reason) + } +} diff --git a/src/internal/websocket.ts b/src/internal/websocket.ts new file mode 100644 index 00000000..be0fd98b --- /dev/null +++ b/src/internal/websocket.ts @@ -0,0 +1,112 @@ +import EventEmitter from 'eventemitter3' +import { Logger } from '../utils/logger' + +export const timeout = 15000 + +export interface NekoWebSocketEvents { + connecting: () => void + connected: () => void + disconnected: (error?: Error) => void +} + +export class NekoWebSocket extends EventEmitter { + private _ws?: WebSocket + private _timeout?: NodeJS.Timeout + private _log: Logger + + constructor() { + super() + + this._log = new Logger('websocket') + } + + get connected() { + return typeof this._ws !== 'undefined' && this._ws.readyState === WebSocket.OPEN + } + + public connect(url: string, password: string) { + if (this.connected) { + throw new Error('attempting to create websocket while connection open') + } + + this.emit('connecting') + + this._ws = new WebSocket(`${url}ws?password=${password}`) + this._log.debug(`connecting to ${this._ws.url}`) + + this._ws.onopen = this.onConnected.bind(this) + this._ws.onclose = this.onDisconnected.bind(this, new Error('websocket closed')) + this._ws.onerror = this.onError.bind(this) + this._ws.onmessage = this.onMessage.bind(this) + + this._timeout = setTimeout(this.onTimeout.bind(this), timeout) + } + + public disconnect() { + if (this._timeout) { + clearTimeout(this._timeout) + } + + if (this.connected) { + try { + this._ws!.close() + } catch (err) {} + + this._ws = undefined + } + } + + public send(event: string, payload?: any) { + if (!this.connected) { + this._log.warn(`attempting to send message while disconnected`) + return + } + + this._log.debug(`sending event '${event}' ${payload ? `with payload: ` : ''}`, payload) + this._ws!.send(JSON.stringify({ event, ...payload })) + } + + private onMessage(e: MessageEvent) { + const { event, ...payload } = JSON.parse(e.data) + this._log.debug(`received websocket event ${event} ${payload ? `with payload: ` : ''}`, payload) + + // @ts-ignore + if (typeof this[event] === 'function') { + // @ts-ignore + // TODO: REFACTOR + this[event](payload) + } else { + this._log.warn(`unhandled websocket event '${event}':`, payload) + } + } + + private onError(event: Event) { + this._log.error((event as ErrorEvent).error) + } + + private onConnected() { + if (this._timeout) { + clearTimeout(this._timeout) + } + + if (!this.connected) { + this._log.warn(`onConnected called while being disconnected`) + return + } + + this._log.debug(`connected`) + this.emit('connected') + } + + private onTimeout() { + this._log.debug(`connection timeout`) + this.onDisconnected(new Error('connection timeout')) + } + + private onDisconnected(reason?: Error) { + this._log.debug(`disconnected:`, reason) + + this.disconnect() + this.emit('disconnected', reason) + } +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 00000000..8c9cfb79 --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,25 @@ +export class Logger { + private _scope: string = 'main' + + constructor(scope?: string) { + if(scope) { + this._scope = scope + } + } + + public error(error: Error) { + console.error('[%cNEKO%c] ['+this._scope+'] %cERR', 'color: #498ad8;', '', 'color: #d84949;', error) + } + + public warn(...log: any[]) { + console.warn('[%cNEKO%c] ['+this._scope+'] %cWRN', 'color: #498ad8;', '', 'color: #eae364;', ...log) + } + + public info(...log: any[]) { + console.info('[%cNEKO%c] ['+this._scope+'] %cINF', 'color: #498ad8;', '', 'color: #4ac94c;', ...log) + } + + public debug(...log: any[]) { + console.log('[%cNEKO%c] ['+this._scope+'] %cDBG', 'color: #498ad8;', '', 'color: #eae364;', ...log) + } +}