Added WebSocket handlers for new connections and messages received through the default EFT socket (!339)

Co-authored-by: clodan <clodan@clodan.com>
Reviewed-on: https://dev.sp-tarkov.com/SPT-AKI/Server/pulls/339
Co-authored-by: Alex <clodan@noreply.dev.sp-tarkov.com>
Co-committed-by: Alex <clodan@noreply.dev.sp-tarkov.com>
This commit is contained in:
Alex 2024-05-18 16:45:21 +00:00 committed by chomp
parent 3163e5cd0c
commit 5b5bf8bd7c
10 changed files with 193 additions and 106 deletions

View File

@ -70,12 +70,12 @@
"@yao-pkg/pkg-fetch": "3.5.9",
"cross-env": "~7.0",
"eslint": "~8.57",
"eslint-config-prettier": "^9.1.0",
"eslint-config-prettier": "~9.1",
"eslint-import-resolver-typescript": "~3.6",
"eslint-plugin-import": "~2.29",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-switch-allman": "^1.0.2",
"eslint-plugin-unused-imports": "^3.2.0",
"eslint-plugin-prettier": "~5.1",
"eslint-plugin-switch-allman": "~1.0",
"eslint-plugin-unused-imports": "~3.2",
"fs-extra": "~11.2",
"gulp": "~4.0",
"gulp-decompress": "~3.0",
@ -84,11 +84,11 @@
"gulp-rename": "~2.0",
"madge": "~6.1",
"minimist": "~1.2",
"prettier": "^3.2.5",
"prettier": "~3.2",
"resedit": "~2.0",
"ts-node-dev": "~2.0",
"tsconfig-paths": "~4.2",
"tslint-config-prettier": "^1.18.0",
"tslint-config-prettier": "~1.18",
"typedoc": "~0.25",
"typemoq": "~2.1",
"typescript-eslint": "~7.8",

View File

@ -192,6 +192,10 @@ import { HttpServer } from "@spt-aki/servers/HttpServer";
import { RagfairServer } from "@spt-aki/servers/RagfairServer";
import { SaveServer } from "@spt-aki/servers/SaveServer";
import { WebSocketServer } from "@spt-aki/servers/WebSocketServer";
import { AkiWebSocketConnectionHandler } from "@spt-aki/servers/ws/AkiWebSocketConnectionHandler";
import { IWebSocketConnectionHandler } from "@spt-aki/servers/ws/IWebSocketConnectionHandler";
import { DefaultAkiWebSocketMessageHandler } from "@spt-aki/servers/ws/message/DefaultAkiWebSocketMessageHandler";
import { IAkiWebSocketMessageHandler } from "@spt-aki/servers/ws/message/IAkiWebSocketMessageHandler";
import { BotEquipmentFilterService } from "@spt-aki/services/BotEquipmentFilterService";
import { BotEquipmentModPoolService } from "@spt-aki/services/BotEquipmentModPoolService";
import { BotGenerationCacheService } from "@spt-aki/services/BotGenerationCacheService";
@ -383,6 +387,12 @@ export class Container
depContainer.registerType("SptCommand", "GiveSptCommand");
depContainer.registerType("SptCommand", "TraderSptCommand");
depContainer.registerType("SptCommand", "ProfileSptCommand");
// WebSocketHandlers
depContainer.registerType("WebSocketConnectionHandler", "AkiWebSocketConnectionHandler");
// WebSocketMessageHandlers
depContainer.registerType("AkiWebSocketMessageHandler", "DefaultAkiWebSocketMessageHandler");
}
private static registerUtils(depContainer: DependencyContainer): void
@ -778,6 +788,8 @@ export class Container
depContainer.register<DatabaseServer>("DatabaseServer", DatabaseServer, { lifecycle: Lifecycle.Singleton });
depContainer.register<HttpServer>("HttpServer", HttpServer, { lifecycle: Lifecycle.Singleton });
depContainer.register<WebSocketServer>("WebSocketServer", WebSocketServer, { lifecycle: Lifecycle.Singleton });
depContainer.register<IWebSocketConnectionHandler>("AkiWebSocketConnectionHandler", AkiWebSocketConnectionHandler, { lifecycle: Lifecycle.Singleton });
depContainer.register<IAkiWebSocketMessageHandler>("DefaultAkiWebSocketMessageHandler", DefaultAkiWebSocketMessageHandler, { lifecycle: Lifecycle.Singleton });
depContainer.register<RagfairServer>("RagfairServer", RagfairServer);
depContainer.register<SaveServer>("SaveServer", SaveServer, { lifecycle: Lifecycle.Singleton });
depContainer.register<ConfigServer>("ConfigServer", ConfigServer, { lifecycle: Lifecycle.Singleton });

View File

@ -191,8 +191,8 @@ export class GiveSptCommand implements ISptCommand
}
}
const localizedGlobal
= this.databaseServer.getTables().locales.global[locale] ?? this.databaseServer.getTables().locales.global.en;
const localizedGlobal = this.databaseServer.getTables().locales.global[locale]
?? this.databaseServer.getTables().locales.global.en;
// If item is an item name, we need to search using that item name and the locale which one we want otherwise
// item is just the tplId.
const tplId = isItemName

View File

@ -6,7 +6,7 @@ import { MemberCategory } from "@spt-aki/models/enums/MemberCategory";
import { MessageType } from "@spt-aki/models/enums/MessageType";
import { NotificationEventType } from "@spt-aki/models/enums/NotificationEventType";
import { SaveServer } from "@spt-aki/servers/SaveServer";
import { WebSocketServer } from "@spt-aki/servers/WebSocketServer";
import { AkiWebSocketConnectionHandler } from "@spt-aki/servers/ws/AkiWebSocketConnectionHandler";
import { NotificationService } from "@spt-aki/services/NotificationService";
import { HashUtil } from "@spt-aki/utils/HashUtil";
@ -14,7 +14,7 @@ import { HashUtil } from "@spt-aki/utils/HashUtil";
export class NotificationSendHelper
{
constructor(
@inject("WebSocketServer") protected webSocketServer: WebSocketServer,
@inject("AkiWebSocketConnectionHandler") protected akiWebSocketConnection: AkiWebSocketConnectionHandler,
@inject("HashUtil") protected hashUtil: HashUtil,
@inject("SaveServer") protected saveServer: SaveServer,
@inject("NotificationService") protected notificationService: NotificationService,
@ -28,9 +28,9 @@ export class NotificationSendHelper
*/
public sendMessage(sessionID: string, notificationMessage: IWsNotificationEvent): void
{
if (this.webSocketServer.isConnectionWebSocket(sessionID))
if (this.akiWebSocketConnection.isConnectionWebSocket(sessionID))
{
this.webSocketServer.sendMessage(sessionID, notificationMessage);
this.akiWebSocketConnection.sendMessage(sessionID, notificationMessage);
}
else
{

View File

@ -1,54 +1,37 @@
import http, { IncomingMessage } from "node:http";
import { inject, injectable } from "tsyringe";
import WebSocket from "ws";
import { inject, injectAll, injectable } from "tsyringe";
import { WebSocket, Server } from "ws";
import { HttpServerHelper } from "@spt-aki/helpers/HttpServerHelper";
import { ProfileHelper } from "@spt-aki/helpers/ProfileHelper";
import { IWsNotificationEvent } from "@spt-aki/models/eft/ws/IWsNotificationEvent";
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
import { NotificationEventType } from "@spt-aki/models/enums/NotificationEventType";
import { IHttpConfig } from "@spt-aki/models/spt/config/IHttpConfig";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
import { LocalisationService } from "@spt-aki/services/LocalisationService";
import { JsonUtil } from "@spt-aki/utils/JsonUtil";
import { RandomUtil } from "@spt-aki/utils/RandomUtil";
import { IWebSocketConnectionHandler } from "./ws/IWebSocketConnectionHandler";
@injectable()
export class WebSocketServer
{
protected webSocketServer: Server;
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@inject("RandomUtil") protected randomUtil: RandomUtil,
@inject("ConfigServer") protected configServer: ConfigServer,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("JsonUtil") protected jsonUtil: JsonUtil,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("HttpServerHelper") protected httpServerHelper: HttpServerHelper,
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
@injectAll("WebSocketConnectionHandler") protected webSocketConnectionHandlers: IWebSocketConnectionHandler[],
)
{
this.httpConfig = this.configServer.getConfig(ConfigTypes.HTTP);
}
protected httpConfig: IHttpConfig;
protected defaultNotification: IWsNotificationEvent = { type: NotificationEventType.PING, eventId: "ping" };
protected webSocketServer: WebSocket.Server;
protected webSockets: Record<string, WebSocket.WebSocket> = {};
protected websocketPingHandler = null;
public getWebSocketServer(): WebSocket.Server
public getWebSocketServer(): Server
{
return this.webSocketServer;
}
public getSessionWebSocket(sessionID: string): WebSocket.WebSocket
{
return this.webSockets[sessionID];
}
public setupWebSocket(httpServer: http.Server): void
{
this.webSocketServer = new WebSocket.Server({ server: httpServer });
this.webSocketServer = new Server({ server: httpServer });
this.webSocketServer.addListener("listening", () =>
{
@ -63,26 +46,6 @@ export class WebSocketServer
this.webSocketServer.addListener("connection", this.wsOnConnection.bind(this));
}
public sendMessage(sessionID: string, output: IWsNotificationEvent): void
{
try
{
if (this.isConnectionWebSocket(sessionID))
{
this.webSockets[sessionID].send(this.jsonUtil.serialize(output));
this.logger.debug(this.localisationService.getText("websocket-message_sent"));
}
else
{
this.logger.debug(this.localisationService.getText("websocket-not_ready_message_not_sent", sessionID));
}
}
catch (err)
{
this.logger.error(this.localisationService.getText("websocket-message_send_failed_with_error", err));
}
}
protected getRandomisedMessage(): string
{
if (this.randomUtil.getInt(1, 1000) > 999)
@ -95,49 +58,21 @@ export class WebSocketServer
: this.localisationService.getText("server_start_success");
}
public isConnectionWebSocket(sessionID: string): boolean
protected wsOnConnection(ws: WebSocket, req: IncomingMessage): void
{
return this.webSockets[sessionID] !== undefined && this.webSockets[sessionID].readyState === WebSocket.OPEN;
}
protected wsOnConnection(ws: WebSocket.WebSocket, req: IncomingMessage): void
{
// Strip request and break it into sections
const splitUrl = req.url.substring(0, req.url.indexOf("?")).split("/");
const sessionID = splitUrl.pop();
const playerProfile = this.profileHelper.getFullProfile(sessionID);
const playerInfoText = `${playerProfile.info.username} (${sessionID})`;
this.logger.info(this.localisationService.getText("websocket-player_connected", playerInfoText));
const logger = this.logger;
const msgToLog = this.localisationService.getText("websocket-received_message", playerInfoText);
ws.on("message", (msg) =>
const socketHandlers = this.webSocketConnectionHandlers.filter((wsh) => req.url.includes(wsh.getHookUrl()));
if ((socketHandlers?.length ?? 0) === 0)
{
logger.info(`${msgToLog} ${msg}`);
});
this.webSockets[sessionID] = ws;
if (this.websocketPingHandler)
{
clearInterval(this.websocketPingHandler);
const message = `Socket connection received for url ${req.url}, but there is not websocket handler configured for it`;
this.logger.warning(message);
ws.send(this.jsonUtil.serialize({ error: message }));
ws.close();
return;
}
this.websocketPingHandler = setInterval(() =>
socketHandlers.forEach((wsh) =>
{
this.logger.debug(this.localisationService.getText("websocket-pinging_player", sessionID));
if (ws.readyState === WebSocket.OPEN)
{
ws.send(this.jsonUtil.serialize(this.defaultNotification));
}
else
{
this.logger.debug(this.localisationService.getText("websocket-socket_lost_deleting_handle"));
clearInterval(this.websocketPingHandler);
delete this.webSockets[sessionID];
}
}, this.httpConfig.webSocketPingDelayMs);
wsh.onConnection(ws, req);
this.logger.info(`WebSocketHandler "${wsh.getSocketId()}" connected`);
});
}
}

View File

@ -0,0 +1,112 @@
import { IncomingMessage } from "http";
import { inject, injectAll, injectable } from "tsyringe";
import { WebSocket } from "ws";
import { ProfileHelper } from "@spt-aki/helpers/ProfileHelper";
import { IWsNotificationEvent } from "@spt-aki/models/eft/ws/IWsNotificationEvent";
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
import { NotificationEventType } from "@spt-aki/models/enums/NotificationEventType";
import { IHttpConfig } from "@spt-aki/models/spt/config/IHttpConfig";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
import { IWebSocketConnectionHandler } from "@spt-aki/servers/ws/IWebSocketConnectionHandler";
import { LocalisationService } from "@spt-aki/services/LocalisationService";
import { JsonUtil } from "@spt-aki/utils/JsonUtil";
import { IAkiWebSocketMessageHandler } from "./message/IAkiWebSocketMessageHandler";
@injectable()
export class AkiWebSocketConnectionHandler implements IWebSocketConnectionHandler
{
protected httpConfig: IHttpConfig;
protected webSockets: Map<string, WebSocket> = new Map<string, WebSocket>();
protected defaultNotification: IWsNotificationEvent = { type: NotificationEventType.PING, eventId: "ping" };
protected websocketPingHandler = null;
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("ConfigServer") protected configServer: ConfigServer,
@inject("JsonUtil") protected jsonUtil: JsonUtil,
@injectAll("AkiWebSocketMessageHandler") protected akiWebSocketMessageHandlers: IAkiWebSocketMessageHandler[],
)
{
this.httpConfig = this.configServer.getConfig(ConfigTypes.HTTP);
}
public getSocketId(): string
{
return "AKI WebSocket Handler";
}
public getHookUrl(): string
{
return "/notifierServer/getwebsocket/";
}
public onConnection(ws: WebSocket, req: IncomingMessage): void
{
// Strip request and break it into sections
const splitUrl = req.url.substring(0, req.url.indexOf("?")).split("/");
const sessionID = splitUrl.pop();
const playerProfile = this.profileHelper.getFullProfile(sessionID);
const playerInfoText = `${playerProfile.info.username} (${sessionID})`;
this.logger.info(this.localisationService.getText("websocket-player_connected", playerInfoText));
// throw new Error("Method not implemented.");
this.webSockets.set(sessionID, ws);
if (this.websocketPingHandler)
{
clearInterval(this.websocketPingHandler);
}
ws.on("message", (msg) => this.akiWebSocketMessageHandlers.forEach((wsmh) => wsmh.onAkiMessage(sessionID, this.webSockets.get(sessionID), msg)));
this.websocketPingHandler = setInterval(() =>
{
this.logger.debug(this.localisationService.getText("websocket-pinging_player", sessionID));
if (ws.readyState === WebSocket.OPEN)
{
ws.send(this.jsonUtil.serialize(this.defaultNotification));
}
else
{
this.logger.debug(this.localisationService.getText("websocket-socket_lost_deleting_handle"));
clearInterval(this.websocketPingHandler);
this.webSockets.delete(sessionID);
}
}, this.httpConfig.webSocketPingDelayMs);
}
public sendMessage(sessionID: string, output: IWsNotificationEvent): void
{
try
{
if (this.isConnectionWebSocket(sessionID))
{
this.webSockets.get(sessionID).send(this.jsonUtil.serialize(output));
this.logger.debug(this.localisationService.getText("websocket-message_sent"));
}
else
{
this.logger.debug(this.localisationService.getText("websocket-not_ready_message_not_sent", sessionID));
}
}
catch (err)
{
this.logger.error(this.localisationService.getText("websocket-message_send_failed_with_error", err));
}
}
public isConnectionWebSocket(sessionID: string): boolean
{
return this.webSockets.has(sessionID) && this.webSockets.get(sessionID).readyState === WebSocket.OPEN;
}
public getSessionWebSocket(sessionID: string): WebSocket
{
return this.webSockets[sessionID];
}
}

View File

@ -0,0 +1,9 @@
import { IncomingMessage } from "node:http";
import { WebSocket } from "ws";
export interface IWebSocketConnectionHandler
{
getSocketId(): string
getHookUrl(): string
onConnection(ws: WebSocket, req: IncomingMessage): void
}

View File

@ -0,0 +1,16 @@
import { inject, injectable } from "tsyringe";
import { RawData, WebSocket } from "ws";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { IAkiWebSocketMessageHandler } from "@spt-aki/servers/ws/message/IAkiWebSocketMessageHandler";
@injectable()
export class DefaultAkiWebSocketMessageHandler implements IAkiWebSocketMessageHandler
{
constructor(@inject("WinstonLogger") protected logger: ILogger)
{}
public onAkiMessage(sessionId: string, client: WebSocket, message: RawData): void
{
this.logger.debug(`[${sessionId}] AKI message received: ${message}`);
}
}

View File

@ -0,0 +1,6 @@
import { RawData, WebSocket } from "ws";
export interface IAkiWebSocketMessageHandler
{
onAkiMessage(sessionID: string, client: WebSocket, message: RawData): void
}

View File

@ -1,7 +1,6 @@
import { inject, injectable } from "tsyringe";
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
import { ILocaleConfig } from "@spt-aki/models/spt/config/ILocaleConfig";
import { ILocaleBase } from "@spt-aki/models/spt/server/ILocaleBase";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
@ -13,7 +12,6 @@ import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
export class LocaleService
{
protected localeConfig: ILocaleConfig;
protected localesTable: ILocaleBase;
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@ -22,7 +20,6 @@ export class LocaleService
)
{
this.localeConfig = this.configServer.getConfig(ConfigTypes.LOCALE);
this.localesTable = this.databaseServer.getTables().locales;
}
/**
@ -31,7 +28,7 @@ export class LocaleService
*/
public getLocaleDb(): Record<string, string>
{
const desiredLocale = this.localesTable.global[this.getDesiredGameLocale()];
const desiredLocale = this.databaseServer.getTables().locales.global[this.getDesiredGameLocale()];
if (desiredLocale)
{
return desiredLocale;
@ -41,7 +38,7 @@ export class LocaleService
`Unable to find desired locale file using locale: ${this.getDesiredGameLocale()} from config/locale.json, falling back to 'en'`,
);
return this.localesTable.global.en;
return this.databaseServer.getTables().locales.global.en;
}
/**
@ -138,19 +135,19 @@ export class LocaleService
}
const baseNameCode = platformLocale.baseName?.toLocaleLowerCase();
if (baseNameCode && this.localesTable.global[baseNameCode])
if (baseNameCode && this.databaseServer.getTables().locales.global[baseNameCode])
{
return baseNameCode;
}
const languageCode = platformLocale.language?.toLowerCase();
if (languageCode && this.localesTable.global[languageCode])
if (languageCode && this.databaseServer.getTables().locales.global[languageCode])
{
return languageCode;
}
const regionCode = platformLocale.region?.toLocaleLowerCase();
if (regionCode && this.localesTable.global[regionCode])
if (regionCode && this.databaseServer.getTables().locales.global[regionCode])
{
return regionCode;
}