sprinklers3/client/sprinklersRpc/WebSocketRpcClient.ts

308 lines
10 KiB
TypeScript
Raw Normal View History

import { action, computed, observable, runInAction, when } from "mobx";
2017-10-09 08:09:08 -06:00
import { update } from "serializr";
2018-08-07 21:21:26 +03:00
import { TokenStore } from "@client/state/TokenStore";
import { ErrorCode } from "@common/ErrorCode";
import { IUser } from "@common/httpApi";
import * as rpc from "@common/jsonRpc";
2017-10-09 08:09:08 -06:00
import logger from "@common/logger";
import * as s from "@common/sprinklersRpc";
import * as deviceRequests from "@common/sprinklersRpc/deviceRequests";
import * as schema from "@common/sprinklersRpc/schema/";
import { seralizeRequest } from "@common/sprinklersRpc/schema/requests";
import * as ws from "@common/sprinklersRpc/websocketData";
import { DefaultEvents, TypedEventEmitter, typedEventEmitter } from "@common/TypedEventEmitter";
import { WSSprinklersDevice } from "./WSSprinklersDevice";
2017-10-09 08:09:08 -06:00
export const log = logger.child({ source: "websocket" });
2017-10-09 08:09:08 -06:00
2018-06-25 17:37:36 -06:00
const TIMEOUT_MS = 5000;
2018-06-27 00:59:58 -06:00
const RECONNECT_TIMEOUT_MS = 5000;
2018-06-25 17:37:36 -06:00
const isDev = process.env.NODE_ENV === "development";
const websocketProtocol = (location.protocol === "https:") ? "wss:" : "ws:";
const websocketPort = isDev ? 8080 : location.port;
const DEFAULT_URL = `${websocketProtocol}//${location.hostname}:${websocketPort}`;
export interface WebSocketRpcClientEvents extends DefaultEvents {
newUserData(userData: IUser): void;
rpcError(error: s.RpcError): void;
tokenError(error: s.RpcError): void;
}
// tslint:disable:member-ordering
export interface WebSocketRpcClient extends TypedEventEmitter<WebSocketRpcClientEvents> {
}
@typedEventEmitter
export class WebSocketRpcClient extends s.SprinklersRPC {
@computed
get connected(): boolean {
return this.connectionState.isServerConnected || false;
}
2017-10-09 08:09:08 -06:00
readonly webSocketUrl: string;
devices: Map<string, WSSprinklersDevice> = new Map();
2018-06-16 23:54:03 -06:00
@observable connectionState: s.ConnectionState = new s.ConnectionState();
socket: WebSocket | null = null;
@observable
authenticated: boolean = false;
tokenStore: TokenStore;
private nextRequestId = Math.round(Math.random() * 1000000);
private responseCallbacks: ws.ServerResponseHandlers = {};
2018-06-27 00:59:58 -06:00
private reconnectTimer: number | null = null;
@action
private onDisconnect = action(() => {
this.connectionState.serverToBroker = null;
this.connectionState.clientToServer = false;
this.authenticated = false;
});
private notificationHandlers = new WSClientNotificationHandlers(this);
2018-06-16 23:54:03 -06:00
constructor(tokenStore: TokenStore, webSocketUrl: string = DEFAULT_URL) {
super();
2017-10-09 08:09:08 -06:00
this.webSocketUrl = webSocketUrl;
this.tokenStore = tokenStore;
2018-06-16 23:54:03 -06:00
this.connectionState.clientToServer = false;
this.connectionState.serverToBroker = false;
this.on("rpcError", (err: s.RpcError) => {
if (err.code === ErrorCode.BadToken) {
this.emit("tokenError", err);
}
});
2017-10-09 08:09:08 -06:00
}
start() {
2018-06-27 00:59:58 -06:00
this._connect();
}
stop() {
if (this.reconnectTimer != null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
if (this.socket != null) {
this.socket.close();
this.socket = null;
}
2017-10-09 08:09:08 -06:00
}
acquireDevice = s.SprinklersRPC.prototype.acquireDevice;
protected getDevice(id: string): s.SprinklersDevice {
let device = this.devices.get(id);
if (!device) {
device = new WSSprinklersDevice(this, id);
this.devices.set(id, device);
2017-10-09 08:09:08 -06:00
}
return device;
2017-10-09 08:09:08 -06:00
}
releaseDevice(id: string): void {
const device = this.devices.get(id);
if (!device) return;
device.unsubscribe()
.then(() => {
log.debug({ id }, "released device");
this.devices.delete(id);
});
2017-10-09 08:09:08 -06:00
}
async authenticate(accessToken: string): Promise<ws.IAuthenticateResponse> {
return this.makeRequest("authenticate", { accessToken });
}
async tryAuthenticate() {
when(() => this.connectionState.clientToServer === true
&& this.tokenStore.accessToken.isValid, async () => {
try {
const res = await this.authenticate(this.tokenStore.accessToken.token!);
runInAction("authenticateSuccess", () => {
this.authenticated = res.authenticated;
});
logger.info({ user: res.user }, "authenticated websocket connection");
this.emit("newUserData", res.user);
} catch (err) {
logger.error({ err }, "error authenticating websocket connection");
// TODO message?
runInAction("authenticateError", () => {
this.authenticated = false;
});
}
});
}
2017-10-09 08:09:08 -06:00
// args must all be JSON serializable
async makeDeviceCall(deviceId: string, request: deviceRequests.Request): Promise<deviceRequests.Response> {
2018-06-27 00:59:58 -06:00
if (this.socket == null) {
2018-06-30 23:26:48 -06:00
const error: ws.IError = {
2018-06-27 00:59:58 -06:00
code: ErrorCode.ServerDisconnected,
message: "the server is not connected",
};
throw new s.RpcError("the server is not connected", ErrorCode.ServerDisconnected);
2018-06-27 00:59:58 -06:00
}
const requestData = seralizeRequest(request);
const data: ws.IDeviceCallRequest = { deviceId, data: requestData };
const resData = await this.makeRequest("deviceCall", data);
if (resData.data.result === "error") {
throw new s.RpcError(resData.data.message, resData.data.code, resData.data);
} else {
return resData.data;
}
}
makeRequest<Method extends ws.ClientRequestMethods>(method: Method, params: ws.IClientRequestTypes[Method]):
Promise<ws.IServerResponseTypes[Method]> {
const id = this.nextRequestId++;
return new Promise<ws.IServerResponseTypes[Method]>((resolve, reject) => {
2018-06-25 17:37:36 -06:00
let timeoutHandle: number;
this.responseCallbacks[id] = (response) => {
2018-06-25 17:37:36 -06:00
clearTimeout(timeoutHandle);
delete this.responseCallbacks[id];
if (response.result === "success") {
resolve(response.data);
2017-10-09 08:09:08 -06:00
} else {
const { error } = response;
reject(new s.RpcError(error.message, error.code, error.data));
2017-10-09 08:09:08 -06:00
}
};
2018-06-27 00:59:58 -06:00
timeoutHandle = window.setTimeout(() => {
delete this.responseCallbacks[id];
reject(new s.RpcError("the request timed out", ErrorCode.Timeout));
2018-06-25 17:37:36 -06:00
}, TIMEOUT_MS);
this.sendRequest(id, method, params);
})
.catch((err) => {
if (err instanceof s.RpcError) {
this.emit("rpcError", err);
}
throw err;
});
}
private sendMessage(data: ws.ClientMessage) {
if (!this.socket) {
throw new Error("WebSocketApiClient is not connected");
}
2017-10-09 08:09:08 -06:00
this.socket.send(JSON.stringify(data));
}
private sendRequest<Method extends ws.ClientRequestMethods>(
id: number, method: Method, params: ws.IClientRequestTypes[Method],
) {
this.sendMessage({ type: "request", id, method, params });
2017-10-09 08:09:08 -06:00
}
2018-06-27 00:59:58 -06:00
private _reconnect = () => {
this._connect();
}
private _connect() {
2018-08-11 19:59:20 +03:00
if (this.socket != null &&
(this.socket.readyState === WebSocket.OPEN)) {
2018-08-11 19:59:20 +03:00
this.tryAuthenticate();
return;
}
log.debug({ url: this.webSocketUrl }, "connecting to websocket");
2018-06-27 00:59:58 -06:00
this.socket = new WebSocket(this.webSocketUrl);
this.socket.onopen = this.onOpen.bind(this);
this.socket.onclose = this.onClose.bind(this);
this.socket.onerror = this.onError.bind(this);
this.socket.onmessage = this.onMessage.bind(this);
}
@action
2017-10-09 08:09:08 -06:00
private onOpen() {
log.info("established websocket connection");
2018-06-16 23:54:03 -06:00
this.connectionState.clientToServer = true;
this.authenticated = false;
this.tryAuthenticate();
2017-10-09 08:09:08 -06:00
}
private onClose(event: CloseEvent) {
2018-08-16 14:33:19 -06:00
log.info({ event },
2017-10-09 08:09:08 -06:00
"disconnected from websocket");
2018-06-17 01:04:30 -06:00
this.onDisconnect();
2018-06-27 00:59:58 -06:00
this.reconnectTimer = window.setTimeout(this._reconnect, RECONNECT_TIMEOUT_MS);
2017-10-09 08:09:08 -06:00
}
@action
2017-10-09 08:09:08 -06:00
private onError(event: Event) {
2018-06-17 01:04:30 -06:00
log.error({ event }, "websocket error");
this.connectionState.serverToBroker = null;
this.connectionState.clientToServer = false;
2018-06-17 01:04:30 -06:00
this.onDisconnect();
2017-10-09 08:09:08 -06:00
}
private onMessage(event: MessageEvent) {
let data: ws.ServerMessage;
2017-10-09 08:09:08 -06:00
try {
data = JSON.parse(event.data);
} catch (err) {
return log.error({ event, err }, "received invalid websocket message");
}
2018-06-16 23:54:03 -06:00
log.trace({ data }, "websocket message");
2017-10-09 08:09:08 -06:00
switch (data.type) {
case "notification":
this.onNotification(data);
2018-06-29 18:16:06 -06:00
break;
case "response":
this.onResponse(data);
2018-06-16 23:54:03 -06:00
break;
2017-10-09 08:09:08 -06:00
default:
log.warn({ data }, "unsupported event type received");
}
}
private onNotification(data: ws.ServerNotification) {
try {
rpc.handleNotification(this.notificationHandlers, data);
} catch (err) {
2018-08-13 15:11:36 +03:00
logger.error(err, "error handling server notification");
2017-10-09 08:09:08 -06:00
}
}
private onResponse(data: ws.ServerResponse) {
try {
rpc.handleResponse(this.responseCallbacks, data);
} catch (err) {
log.error({ err }, "error handling server response");
2017-10-09 08:09:08 -06:00
}
}
}
2018-08-13 15:11:36 +03:00
class WSClientNotificationHandlers implements ws.ServerNotificationHandlers {
client: WebSocketRpcClient;
constructor(client: WebSocketRpcClient) {
this.client = client;
}
@action.bound
brokerConnectionUpdate(data: ws.IBrokerConnectionUpdate) {
this.client.connectionState.serverToBroker = data.brokerConnected;
}
@action.bound
deviceUpdate(data: ws.IDeviceUpdate) {
const device = this.client.devices.get(data.deviceId);
if (!device) {
return log.warn({ data }, "invalid deviceUpdate received");
}
update(schema.sprinklersDevice, device, data.data);
}
error(data: ws.IError) {
log.warn({ err: data }, "server error");
}
}