Alex Mikhalev
6 years ago
21 changed files with 616 additions and 408 deletions
@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
|
||||
import { ErrorCode } from "@common/ErrorCode"; |
||||
import { IError } from "./websocketData"; |
||||
|
||||
export class RpcError extends Error implements IError { |
||||
name = "RpcError"; |
||||
code: number; |
||||
data: any; |
||||
|
||||
constructor(message: string, code: number = ErrorCode.BadRequest, data: any = {}) { |
||||
super(message); |
||||
this.code = code; |
||||
if (data instanceof Error) { |
||||
this.data = data.toString(); |
||||
} |
||||
this.data = data; |
||||
} |
||||
|
||||
toJSON(): IError { |
||||
return { code: this.code, message: this.message, data: this.data }; |
||||
} |
||||
} |
@ -1,13 +1,31 @@
@@ -1,13 +1,31 @@
|
||||
import { ConnectionState } from "./ConnectionState"; |
||||
import { SprinklersDevice } from "./SprinklersDevice"; |
||||
|
||||
export interface SprinklersRPC { |
||||
readonly connectionState: ConnectionState; |
||||
readonly connected: boolean; |
||||
export abstract class SprinklersRPC { |
||||
abstract readonly connectionState: ConnectionState; |
||||
abstract readonly connected: boolean; |
||||
|
||||
start(): void; |
||||
abstract start(): void; |
||||
|
||||
getDevice(id: string): SprinklersDevice; |
||||
/** |
||||
* Acquires a reference to a device. This reference must be released by calling |
||||
* SprinklersDevice#release for every time this method was called |
||||
* @param id The id of the device |
||||
*/ |
||||
acquireDevice(id: string): SprinklersDevice { |
||||
const device = this.getDevice(id); |
||||
device.acquire(); |
||||
return device; |
||||
} |
||||
|
||||
/** |
||||
* Forces a device to be released. The device will no longer be updated. |
||||
* |
||||
* This should not be used normally, instead SprinklersDevice#release should be called to manage |
||||
* each reference to a device. |
||||
* @param id The id of the device to remove |
||||
*/ |
||||
abstract releaseDevice(id: string): void; |
||||
|
||||
removeDevice(id: string): void; |
||||
protected abstract getDevice(id: string): SprinklersDevice; |
||||
} |
||||
|
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
import * as WebSocket from "ws"; |
||||
|
||||
import { ServerState } from "@server/state"; |
||||
import { WebSocketConnection } from "./WebSocketConnection"; |
||||
|
||||
export class WebSocketApi { |
||||
state: ServerState; |
||||
clients: Set<WebSocketConnection> = new Set(); |
||||
|
||||
constructor(state: ServerState) { |
||||
this.state = state; |
||||
} |
||||
|
||||
listen(webSocketServer: WebSocket.Server) { |
||||
webSocketServer.on("connection", this.handleConnection); |
||||
} |
||||
|
||||
handleConnection = (socket: WebSocket) => { |
||||
const client = new WebSocketConnection(this, socket); |
||||
this.clients.add(client); |
||||
} |
||||
|
||||
removeClient(client: WebSocketConnection) { |
||||
return this.clients.delete(client); |
||||
} |
||||
} |
@ -0,0 +1,262 @@
@@ -0,0 +1,262 @@
|
||||
import { autorun } from "mobx"; |
||||
import { serialize } from "serializr"; |
||||
import * as WebSocket from "ws"; |
||||
|
||||
import { ErrorCode } from "@common/ErrorCode"; |
||||
import * as rpc from "@common/jsonRpc"; |
||||
import log from "@common/logger"; |
||||
import { RpcError } from "@common/sprinklersRpc"; |
||||
import * as deviceRequests from "@common/sprinklersRpc/deviceRequests"; |
||||
import * as schema from "@common/sprinklersRpc/schema"; |
||||
import * as ws from "@common/sprinklersRpc/websocketData"; |
||||
import { AccessToken } from "@common/TokenClaims"; |
||||
import { User } from "@server/entities"; |
||||
import { verifyToken } from "@server/express/authentication"; |
||||
|
||||
import { WebSocketApi } from "./WebSocketApi"; |
||||
|
||||
type Disposer = () => void; |
||||
|
||||
export class WebSocketConnection { |
||||
api: WebSocketApi; |
||||
socket: WebSocket; |
||||
|
||||
disposers: Array<() => void> = []; |
||||
// map of device id to disposer function
|
||||
deviceSubscriptions: Map<string, Disposer> = new Map(); |
||||
|
||||
/// This shall be the user id if the client has been authenticated, null otherwise
|
||||
userId: number | null = null; |
||||
user: User | null = null; |
||||
|
||||
private requestHandlers: ws.ClientRequestHandlers = new WebSocketRequestHandlers(); |
||||
|
||||
get state() { |
||||
return this.api.state; |
||||
} |
||||
|
||||
constructor(api: WebSocketApi, socket: WebSocket) { |
||||
this.api = api; |
||||
this.socket = socket; |
||||
|
||||
this.socket.on("message", this.handleSocketMessage); |
||||
this.socket.on("close", this.onClose); |
||||
} |
||||
|
||||
stop = () => { |
||||
this.socket.close(); |
||||
} |
||||
|
||||
onClose = (code: number, reason: string) => { |
||||
log.debug({ code, reason }, "WebSocketConnection closing"); |
||||
this.disposers.forEach((disposer) => disposer()); |
||||
this.deviceSubscriptions.forEach((disposer) => disposer()); |
||||
this.api.removeClient(this); |
||||
} |
||||
|
||||
subscribeBrokerConnection() { |
||||
this.disposers.push(autorun(() => { |
||||
const updateData: ws.IBrokerConnectionUpdate = { |
||||
brokerConnected: this.state.mqttClient.connected, |
||||
}; |
||||
this.sendNotification("brokerConnectionUpdate", updateData); |
||||
})); |
||||
} |
||||
|
||||
checkAuthorization() { |
||||
if (!this.userId || !this.user) { |
||||
throw new RpcError("this WebSocket session has not been authenticated", |
||||
ErrorCode.Unauthorized); |
||||
} |
||||
} |
||||
|
||||
checkDevice(devId: string) { |
||||
const userDevice = this.user!.devices!.find((dev) => dev.deviceId === devId); |
||||
if (userDevice == null) { |
||||
throw new RpcError("you do not have permission to subscribe to device", |
||||
ErrorCode.NoPermission, { id: devId }); |
||||
} |
||||
const deviceId = userDevice.deviceId; |
||||
if (!deviceId) { |
||||
throw new RpcError("device has no associated device prefix", ErrorCode.Internal); |
||||
} |
||||
return userDevice; |
||||
} |
||||
|
||||
sendMessage(data: ws.ServerMessage) { |
||||
this.socket.send(JSON.stringify(data)); |
||||
} |
||||
|
||||
sendNotification<Method extends ws.ServerNotificationMethod>( |
||||
method: Method, |
||||
data: ws.IServerNotificationTypes[Method]) { |
||||
this.sendMessage({ type: "notification", method, data }); |
||||
} |
||||
|
||||
sendResponse<Method extends ws.ClientRequestMethods>( |
||||
method: Method, |
||||
id: number, |
||||
data: ws.ServerResponseData<Method>) { |
||||
this.sendMessage({ type: "response", method, id, ...data }); |
||||
} |
||||
|
||||
handleSocketMessage = (socketData: WebSocket.Data) => { |
||||
this.doHandleSocketMessage(socketData) |
||||
.catch((err) => { |
||||
this.onError({ err }, "unhandled error on handling socket message"); |
||||
}); |
||||
} |
||||
|
||||
async doDeviceCallRequest(requestData: ws.IDeviceCallRequest): Promise<deviceRequests.Response> { |
||||
const userDevice = this.checkDevice(requestData.deviceId); |
||||
const deviceId = userDevice.deviceId!; |
||||
const device = this.state.mqttClient.acquireDevice(deviceId); |
||||
try { |
||||
const request = schema.requests.deserializeRequest(requestData.data); |
||||
return await device.makeRequest(request); |
||||
} finally { |
||||
device.release(); |
||||
} |
||||
} |
||||
|
||||
private async doHandleSocketMessage(socketData: WebSocket.Data) { |
||||
if (typeof socketData !== "string") { |
||||
return this.onError({ type: typeof socketData }, |
||||
"received invalid socket data type from client", ErrorCode.Parse); |
||||
} |
||||
let data: ws.ClientMessage; |
||||
try { |
||||
data = JSON.parse(socketData); |
||||
} catch (err) { |
||||
return this.onError({ socketData, err }, "received invalid websocket message from client", |
||||
ErrorCode.Parse); |
||||
} |
||||
switch (data.type) { |
||||
case "request": |
||||
await this.handleRequest(data); |
||||
break; |
||||
default: |
||||
return this.onError({ data }, "received invalid message type from client", |
||||
ErrorCode.BadRequest); |
||||
} |
||||
} |
||||
|
||||
private async handleRequest(request: ws.ClientRequest) { |
||||
let response: ws.ServerResponseData; |
||||
try { |
||||
if (!this.requestHandlers[request.method]) { |
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new RpcError("received invalid client request method"); |
||||
} |
||||
response = await rpc.handleRequest(this.requestHandlers, request, this); |
||||
} catch (err) { |
||||
if (err instanceof RpcError) { |
||||
log.debug({ err }, "rpc error"); |
||||
response = { result: "error", error: err.toJSON() }; |
||||
} else { |
||||
log.error({ method: request.method, err }, "unhandled error during processing of client request"); |
||||
response = { |
||||
result: "error", error: { |
||||
code: ErrorCode.Internal, message: "unhandled error during processing of client request", |
||||
data: err.toString(), |
||||
}, |
||||
}; |
||||
} |
||||
} |
||||
this.sendResponse(request.method, request.id, response); |
||||
} |
||||
|
||||
private onError(data: any, message: string, code: number = ErrorCode.Internal) { |
||||
log.error(data, message); |
||||
const errorData: ws.IError = { code, message, data }; |
||||
this.sendNotification("error", errorData); |
||||
} |
||||
} |
||||
|
||||
class WebSocketRequestHandlers implements ws.ClientRequestHandlers { |
||||
async authenticate(this: WebSocketConnection, data: ws.IAuthenticateRequest): |
||||
Promise<ws.ServerResponseData<"authenticate">> { |
||||
if (!data.accessToken) { |
||||
throw new RpcError("no token specified", ErrorCode.BadRequest); |
||||
} |
||||
let claims: AccessToken; |
||||
try { |
||||
claims = await verifyToken<AccessToken>(data.accessToken, "access"); |
||||
} catch (e) { |
||||
throw new RpcError("invalid token", ErrorCode.BadToken, e); |
||||
} |
||||
this.userId = claims.aud; |
||||
this.user = await this.state.database.users. |
||||
findById(this.userId, { devices: true }) || null; |
||||
if (!this.user) { |
||||
throw new RpcError("user no longer exists", ErrorCode.BadToken); |
||||
} |
||||
log.debug({ userId: claims.aud, name: claims.name }, "authenticated websocket client"); |
||||
this.subscribeBrokerConnection(); |
||||
return { |
||||
result: "success", |
||||
data: { authenticated: true, message: "authenticated", user: this.user.toJSON() }, |
||||
}; |
||||
} |
||||
|
||||
async deviceSubscribe(this: WebSocketConnection, data: ws.IDeviceSubscribeRequest): |
||||
Promise<ws.ServerResponseData<"deviceSubscribe">> { |
||||
this.checkAuthorization(); |
||||
const userDevice = this.checkDevice(data.deviceId); |
||||
const deviceId = userDevice.deviceId!; |
||||
if (!this.deviceSubscriptions.has(deviceId)) { |
||||
const device = this.state.mqttClient.acquireDevice(deviceId); |
||||
log.debug({ deviceId, userId: this.userId }, "websocket client subscribed to device"); |
||||
|
||||
const autorunDisposer = autorun(() => { |
||||
const json = serialize(schema.sprinklersDevice, device); |
||||
log.trace({ device: json }); |
||||
const updateData: ws.IDeviceUpdate = { deviceId, data: json }; |
||||
this.sendNotification("deviceUpdate", updateData); |
||||
}, { delay: 100 }); |
||||
|
||||
this.deviceSubscriptions.set(deviceId, () => { |
||||
autorunDisposer(); |
||||
device.release(); |
||||
this.deviceSubscriptions.delete(deviceId); |
||||
}); |
||||
} |
||||
|
||||
const response: ws.IDeviceSubscribeResponse = { |
||||
deviceId, |
||||
}; |
||||
return { result: "success", data: response }; |
||||
} |
||||
|
||||
async deviceUnsubscribe(this: WebSocketConnection, data: ws.IDeviceSubscribeRequest): |
||||
Promise<ws.ServerResponseData<"deviceUnsubscribe">> { |
||||
this.checkAuthorization(); |
||||
const userDevice = this.checkDevice(data.deviceId); |
||||
const deviceId = userDevice.deviceId!; |
||||
const disposer = this.deviceSubscriptions.get(deviceId); |
||||
|
||||
if (disposer) { |
||||
disposer(); |
||||
} |
||||
|
||||
const response: ws.IDeviceSubscribeResponse = { |
||||
deviceId, |
||||
}; |
||||
return { result: "success", data: response }; |
||||
} |
||||
|
||||
async deviceCall(this: WebSocketConnection, data: ws.IDeviceCallRequest): |
||||
Promise<ws.ServerResponseData<"deviceCall">> { |
||||
this.checkAuthorization(); |
||||
try { |
||||
const response = await this.doDeviceCallRequest(data); |
||||
const resData: ws.IDeviceCallResponse = { |
||||
data: response, |
||||
}; |
||||
return { result: "success", data: resData }; |
||||
} catch (err) { |
||||
const e: deviceRequests.ErrorResponseData = err; |
||||
throw new RpcError(e.message, e.code, e); |
||||
} |
||||
} |
||||
} |
@ -1,247 +0,0 @@
@@ -1,247 +0,0 @@
|
||||
import { autorun } from "mobx"; |
||||
import { serialize } from "serializr"; |
||||
import * as WebSocket from "ws"; |
||||
|
||||
import { ErrorCode } from "@common/ErrorCode"; |
||||
import * as rpc from "@common/jsonRpc"; |
||||
import log from "@common/logger"; |
||||
import * as deviceRequests from "@common/sprinklersRpc/deviceRequests"; |
||||
import * as schema from "@common/sprinklersRpc/schema"; |
||||
import * as ws from "@common/sprinklersRpc/websocketData"; |
||||
import { AccessToken } from "@common/TokenClaims"; |
||||
import { User } from "@server/entities"; |
||||
import { verifyToken } from "@server/express/authentication"; |
||||
import { ServerState } from "@server/state"; |
||||
|
||||
// tslint:disable:member-ordering
|
||||
|
||||
export class WebSocketClient { |
||||
api: WebSocketApi; |
||||
socket: WebSocket; |
||||
|
||||
disposers: Array<() => void> = []; |
||||
deviceSubscriptions: string[] = []; |
||||
|
||||
/// This shall be the user id if the client has been authenticated, null otherwise
|
||||
userId: number | null = null; |
||||
user: User | null = null; |
||||
|
||||
get state() { |
||||
return this.api.state; |
||||
} |
||||
|
||||
constructor(api: WebSocketApi, socket: WebSocket) { |
||||
this.api = api; |
||||
this.socket = socket; |
||||
} |
||||
|
||||
start() { |
||||
this.socket.on("message", this.handleSocketMessage); |
||||
this.socket.on("close", this.stop); |
||||
} |
||||
|
||||
stop = () => { |
||||
this.disposers.forEach((disposer) => disposer()); |
||||
this.api.removeClient(this); |
||||
} |
||||
|
||||
private subscribeBrokerConnection() { |
||||
this.disposers.push(autorun(() => { |
||||
const updateData: ws.IBrokerConnectionUpdate = { |
||||
brokerConnected: this.state.mqttClient.connected, |
||||
}; |
||||
this.sendNotification("brokerConnectionUpdate", updateData); |
||||
})); |
||||
} |
||||
|
||||
private checkAuthorization() { |
||||
if (!this.userId || !this.user) { |
||||
throw new ws.RpcError("this WebSocket session has not been authenticated", |
||||
ErrorCode.Unauthorized); |
||||
} |
||||
} |
||||
|
||||
private checkDevice(devId: string) { |
||||
const userDevice = this.user!.devices!.find((dev) => dev.deviceId === devId); |
||||
if (userDevice == null) { |
||||
throw new ws.RpcError("you do not have permission to subscribe to this device", |
||||
ErrorCode.NoPermission); |
||||
} |
||||
const deviceId = userDevice.deviceId; |
||||
if (!deviceId) { |
||||
throw new ws.RpcError("device has no associated device prefix", ErrorCode.BadRequest); |
||||
} |
||||
return userDevice; |
||||
} |
||||
|
||||
private requestHandlers: ws.ClientRequestHandlers = { |
||||
authenticate: async (data: ws.IAuthenticateRequest) => { |
||||
if (!data.accessToken) { |
||||
throw new ws.RpcError("no token specified", ErrorCode.BadRequest); |
||||
} |
||||
let claims: AccessToken; |
||||
try { |
||||
claims = await verifyToken<AccessToken>(data.accessToken, "access"); |
||||
} catch (e) { |
||||
throw new ws.RpcError("invalid token", ErrorCode.BadToken, e); |
||||
} |
||||
this.userId = claims.aud; |
||||
this.user = await this.state.database.users. |
||||
findById(this.userId, { devices: true }) || null; |
||||
if (!this.user) { |
||||
throw new ws.RpcError("user no longer exists", ErrorCode.BadToken); |
||||
} |
||||
log.info({ userId: claims.aud, name: claims.name }, "authenticated websocket client"); |
||||
this.subscribeBrokerConnection(); |
||||
return { |
||||
result: "success", |
||||
data: { authenticated: true, message: "authenticated", user: this.user.toJSON() }, |
||||
}; |
||||
}, |
||||
deviceSubscribe: async (data: ws.IDeviceSubscribeRequest) => { |
||||
this.checkAuthorization(); |
||||
const userDevice = this.checkDevice(data.deviceId); |
||||
const deviceId = userDevice.deviceId!; |
||||
if (this.deviceSubscriptions.indexOf(deviceId) === -1) { |
||||
this.deviceSubscriptions.push(deviceId); |
||||
const device = this.state.mqttClient.getDevice(deviceId); |
||||
log.debug({ deviceId, userId: this.userId }, "websocket client subscribed to device"); |
||||
this.disposers.push(autorun(() => { |
||||
const json = serialize(schema.sprinklersDevice, device); |
||||
log.trace({ device: json }); |
||||
const updateData: ws.IDeviceUpdate = { deviceId, data: json }; |
||||
this.sendNotification("deviceUpdate", updateData); |
||||
}, { delay: 100 })); |
||||
} |
||||
|
||||
const response: ws.IDeviceSubscribeResponse = { |
||||
deviceId, |
||||
}; |
||||
return { result: "success", data: response }; |
||||
}, |
||||
deviceCall: async (data: ws.IDeviceCallRequest) => { |
||||
this.checkAuthorization(); |
||||
try { |
||||
const response = await this.doDeviceCallRequest(data); |
||||
const resData: ws.IDeviceCallResponse = { |
||||
data: response, |
||||
}; |
||||
return { result: "success", data: resData }; |
||||
} catch (err) { |
||||
const e: deviceRequests.ErrorResponseData = err; |
||||
throw new ws.RpcError(e.message, e.code, e); |
||||
} |
||||
}, |
||||
}; |
||||
|
||||
private sendMessage(data: ws.ServerMessage) { |
||||
this.socket.send(JSON.stringify(data)); |
||||
} |
||||
|
||||
private sendNotification<Method extends ws.ServerNotificationMethod>( |
||||
method: Method, |
||||
data: ws.IServerNotificationTypes[Method]) { |
||||
this.sendMessage({ type: "notification", method, data }); |
||||
} |
||||
|
||||
private sendResponse<Method extends ws.ClientRequestMethods>( |
||||
method: Method, |
||||
id: number, |
||||
data: ws.ServerResponseData<Method>) { |
||||
this.sendMessage({ type: "response", method, id, ...data }); |
||||
} |
||||
|
||||
private handleSocketMessage = (socketData: WebSocket.Data) => { |
||||
this.doHandleSocketMessage(socketData) |
||||
.catch((err) => { |
||||
this.onError({ err }, "unhandled error on handling socket message"); |
||||
}); |
||||
} |
||||
|
||||
private async doHandleSocketMessage(socketData: WebSocket.Data) { |
||||
if (typeof socketData !== "string") { |
||||
return this.onError({ type: typeof socketData }, |
||||
"received invalid socket data type from client", ErrorCode.Parse); |
||||
} |
||||
let data: ws.ClientMessage; |
||||
try { |
||||
data = JSON.parse(socketData); |
||||
} catch (err) { |
||||
return this.onError({ socketData, err }, "received invalid websocket message from client", |
||||
ErrorCode.Parse); |
||||
} |
||||
switch (data.type) { |
||||
case "request": |
||||
await this.handleRequest(data); |
||||
break; |
||||
default: |
||||
return this.onError({ data }, "received invalid message type from client", |
||||
ErrorCode.BadRequest); |
||||
} |
||||
} |
||||
|
||||
private async handleRequest(request: ws.ClientRequest) { |
||||
let response: ws.ServerResponseData; |
||||
try { |
||||
if (!this.requestHandlers[request.method]) { |
||||
// noinspection ExceptionCaughtLocallyJS
|
||||
throw new ws.RpcError("received invalid client request method"); |
||||
} |
||||
response = await rpc.handleRequest(this.requestHandlers, request); |
||||
} catch (err) { |
||||
if (err instanceof ws.RpcError) { |
||||
log.debug({ err }, "rpc error"); |
||||
response = { result: "error", error: err.toJSON() }; |
||||
} else { |
||||
log.error({ method: request.method, err }, "unhandled error during processing of client request"); |
||||
response = { |
||||
result: "error", error: { |
||||
code: ErrorCode.Internal, message: "unhandled error during processing of client request", |
||||
data: err.toString(), |
||||
}, |
||||
}; |
||||
} |
||||
} |
||||
this.sendResponse(request.method, request.id, response); |
||||
} |
||||
|
||||
private onError(data: any, message: string, code: number = ErrorCode.Internal) { |
||||
log.error(data, message); |
||||
const errorData: ws.IError = { code, message, data }; |
||||
this.sendNotification("error", errorData); |
||||
} |
||||
|
||||
private async doDeviceCallRequest(requestData: ws.IDeviceCallRequest): Promise<deviceRequests.Response> { |
||||
const userDevice = this.checkDevice(requestData.deviceId); |
||||
const deviceId = userDevice.deviceId!; |
||||
const device = this.state.mqttClient.getDevice(deviceId); |
||||
const request = schema.requests.deserializeRequest(requestData.data); |
||||
return device.makeRequest(request); |
||||
} |
||||
} |
||||
|
||||
export class WebSocketApi { |
||||
state: ServerState; |
||||
clients: WebSocketClient[] = []; |
||||
|
||||
constructor(state: ServerState) { |
||||
this.state = state; |
||||
} |
||||
|
||||
listen(webSocketServer: WebSocket.Server) { |
||||
webSocketServer.on("connection", this.handleConnection); |
||||
} |
||||
|
||||
handleConnection = (socket: WebSocket) => { |
||||
const client = new WebSocketClient(this, socket); |
||||
client.start(); |
||||
this.clients.push(client); |
||||
} |
||||
|
||||
removeClient(client: WebSocketClient) { |
||||
const idx = this.clients.indexOf(client); |
||||
if (idx !== -1) { |
||||
this.clients.splice(idx, 1); |
||||
} |
||||
} |
||||
} |
Loading…
Reference in new issue