diff --git a/mobile/package.json b/mobile/package.json index a174f63..7205038 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -17,7 +17,8 @@ "react-i18next": "^14.1.0", "react-native": "0.73.6", "react-native-safe-area-context": "^4.9.0", - "react-native-screens": "^3.29.0" + "react-native-screens": "^3.29.0", + "tiny-emitter": "^2.1.0" }, "devDependencies": { "@babel/core": "^7.20.0", diff --git a/mobile/src/const/channel_names.ts b/mobile/src/const/channel_names.ts new file mode 100644 index 0000000..d088b0b --- /dev/null +++ b/mobile/src/const/channel_names.ts @@ -0,0 +1,13 @@ +export const channel_new_game = "new_game" +export const channel_on_game_start = "on_game_start" +export const channel_game_move = "game_move" +export const channel_on_game_update = "on_game_update" +export const channel_leave_game = "leave_game" +export const channel_on_game_end = "on_game_end" +export const channel_on_game_crashed = "on_game_crashed" +export const channel_on_game_user_leave = "on_game_user_leave" +export const channel_ping = "ping" +export const channel_pong = "pong" +export const channel_on_user_connection_change = "channel_on_user_connection_change" +export const channel_listen_game_events = "channel_listen_game_events" +export const channel_unlisten_game_events = "channel_unlisten_game_events" \ No newline at end of file diff --git a/mobile/src/const/config.ts b/mobile/src/const/config.ts new file mode 100644 index 0000000..590b1fa --- /dev/null +++ b/mobile/src/const/config.ts @@ -0,0 +1,17 @@ +export const useLocalServer = false; +export const isAlpha = true; + +export type Server = { + serverAdress: string; + wsServerAdress: string; +}; + +export const server: Server = useLocalServer ? { + serverAdress: "http://localhost:5005", + wsServerAdress: "ws://localhost:5005", +} : { + serverAdress: "https://mancala.segin.one", + wsServerAdress: "wss://mancala.segin.one", +}; + +export const RTMT_WS_PING_INTERVAL = 1000, RTMT_WS_PING_INTERVAL_BUFFER_TIME = 2000; \ No newline at end of file diff --git a/mobile/src/rtmt/byte_util.ts b/mobile/src/rtmt/byte_util.ts new file mode 100644 index 0000000..a4e550a --- /dev/null +++ b/mobile/src/rtmt/byte_util.ts @@ -0,0 +1,12 @@ +import { Bytes } from "./rtmt" +const textEncoder = new TextEncoder() +const textDecoder = new TextDecoder("utf-8") + +export function encodeText(text : string) { + const bytes = textEncoder.encode(text) + return bytes +} + +export function decodeText(bytes : Bytes) { + return textDecoder.decode(bytes) +} \ No newline at end of file diff --git a/mobile/src/rtmt/encode_decode_message.ts b/mobile/src/rtmt/encode_decode_message.ts new file mode 100644 index 0000000..5dda9c1 --- /dev/null +++ b/mobile/src/rtmt/encode_decode_message.ts @@ -0,0 +1,20 @@ +import { decodeText, encodeText } from "./byte_util"; +import { Bytes } from "./rtmt"; + +const headerLenght = 4 +// +// channel is string, message is byte array +// +export function encode(channel : string, message : Object) { + return JSON.stringify({ + channel, + message + }) +} + +// +// return { channel : string, message : byte array} +// +export function decode(bytes : string) { + return JSON.parse(bytes); +} diff --git a/mobile/src/rtmt/rtmt.ts b/mobile/src/rtmt/rtmt.ts new file mode 100644 index 0000000..08c02c4 --- /dev/null +++ b/mobile/src/rtmt/rtmt.ts @@ -0,0 +1,18 @@ +import { TinyEmitter } from "tiny-emitter" + +export type Bytes = Uint8Array +export type OnMessage = (message: Object) => any + +export type ConnectionState = "none" | "connecting" | "error" | "connected" | "closed" | "reconnecting"; + +export type RtmtEventTypes = "open" | "close" | "connected" | "error" | "disconnected" | "message" | "connectionchange"; + +export interface RTMT extends TinyEmitter { + get connectionState(): ConnectionState; + sendMessage: (channel: string, message: Object) => void; + addMessageListener(channel: string, callback: (message: any) => void): any; + removeMessageListener(channel: string, callback: (message: any) => void): any; + on(event: RtmtEventTypes, callback: (...value: any[]) => void): this; + off(event: RtmtEventTypes, callback: (...value: any[]) => void): this; + dispose(): any; +} \ No newline at end of file diff --git a/mobile/src/rtmt/rtmt_websocket.ts b/mobile/src/rtmt/rtmt_websocket.ts new file mode 100644 index 0000000..7ef29f2 --- /dev/null +++ b/mobile/src/rtmt/rtmt_websocket.ts @@ -0,0 +1,133 @@ +import { decode, encode } from "./encode_decode_message"; +import { RTMT_WS_PING_INTERVAL, RTMT_WS_PING_INTERVAL_BUFFER_TIME, server } from "../const/config"; +import { Bytes, ConnectionState, RTMT, RtmtEventTypes } from "./rtmt"; +import { channel_ping, channel_pong } from "../const/channel_names"; +import { TinyEmitter } from "tiny-emitter"; + +const MESSAGE_CHANNEL_PREFIX = "message_channel"; + +export class RTMTWS extends TinyEmitter implements RTMT { + private webSocket?: WebSocket; + private pingTimeout?: number = undefined; + private _connectionState: ConnectionState = "none"; + private userKey: string = ""; + + get connectionState(): ConnectionState { + return this._connectionState; + } + + protected setConnectionState(connectionState: ConnectionState) { + this._connectionState = connectionState; + this.emit("connectionchange", this._connectionState); + } + + private createWebSocket() { + const url = server.wsServerAdress + "?userKey=" + this.userKey; + const webSocket = new WebSocket(url); + webSocket.onopen = () => this.onWebSocketOpen(webSocket); + webSocket.onclose = () => this.onWebSocketClose(webSocket); + webSocket.onmessage = (event: WebSocketMessageEvent) => this.onWebSocketMessage(webSocket, event); + webSocket.onerror = (error: any) => this.onWebSocketError(webSocket, error); + } + + private disposeWebSocket() { + if (!this.webSocket) return; + this.webSocket.onopen = () => { }; + this.webSocket.onclose = () => { }; + this.webSocket.onmessage = (event: WebSocketMessageEvent) => { }; + this.webSocket.onerror = (error: any) => { }; + this.webSocket = undefined; + } + + public connectWebSocket(userKey: string) { + this.setConnectionState("connecting"); + this.userKey = userKey; + this.createWebSocket(); + } + + private reconnectWebSocket() { + this.setConnectionState("reconnecting"); + this.disposeWebSocket(); + setTimeout(() => this.createWebSocket(), 1000); + } + + protected onWebSocketOpen(webSocket: WebSocket) { + this.webSocket = webSocket; + this.setConnectionState("connected"); + console.info("(RTMT) WebSocket has opened"); + this.heartbeat(); + this.emit("open"); + } + + protected onWebSocketMessage(webSocket: WebSocket, event: WebSocketMessageEvent) { + const { channel, message } = decode(event.data); + this.onMessage(channel, message); + } + + protected onWebSocketError(webSocket: WebSocket, error: any) { + this.setConnectionState("error"); + console.error(error); + this.emit("error", error); + } + + protected onWebSocketClose(webSocket: WebSocket) { + this.setConnectionState("closed"); + console.info("(RTMT) WebSocket has closed"); + //this.WebSocket = undefined + clearTimeout(this.pingTimeout); + this.emit("close"); + this.reconnectWebSocket(); + } + + private heartbeat() { + clearTimeout(this.pingTimeout); + // @ts-ignore + this.pingTimeout = setTimeout(() => { + if (!this.webSocket) return; + console.log("(RTMT) WebSocket self closed"); + this.webSocket.close(); + this.onWebSocketClose(this.webSocket); + }, RTMT_WS_PING_INTERVAL + RTMT_WS_PING_INTERVAL_BUFFER_TIME); + } + + public sendMessage(channel: string, message: Object) { + if (this.webSocket === undefined) { + console.error("(RTMT) WebSocket is undefined"); + return; + } + const data = encode(channel, message); + this.webSocket.send(data); + } + + private onMessage(channel: string, message: Bytes) { + if (channel === channel_ping) { + this.heartbeat(); + this.sendMessage(channel_pong, {}); + return; + } + // TODO: Maybe we should warn if there is not any listener for channel + this.emit(MESSAGE_CHANNEL_PREFIX + channel, message); + } + + public on(event: RtmtEventTypes, callback: (...value: any[]) => void): Listener | this { + return super.on(event, callback); + } + + public off(event: RtmtEventTypes, callback: (...value: any[]) => void): this { + return super.off(event, callback); + } + + public addMessageListener(channel: string, callback: (message: any) => void) { + super.on(MESSAGE_CHANNEL_PREFIX + channel, callback); + } + + public removeMessageListener(channel: string, callback: (message: any) => void) { + super.off(MESSAGE_CHANNEL_PREFIX + channel, callback); + } + + public dispose() { + this.disposeWebSocket(); + // TODO + //this.removeAllListeners(); + } +} diff --git a/mobile/yarn.lock b/mobile/yarn.lock index 45242e1..328665e 100644 --- a/mobile/yarn.lock +++ b/mobile/yarn.lock @@ -6377,6 +6377,11 @@ through2@^2.0.1: readable-stream "~2.3.6" xtend "~4.0.1" +tiny-emitter@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" + integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== + tmpl@1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc"