Skip to content

Commit e8b5f29

Browse files
authored
Merge pull request #371 from TaloDev/maps-logging
Maps and logging
2 parents 11fcceb + cdc9e01 commit e8b5f29

File tree

6 files changed

+213
-28
lines changed

6 files changed

+213
-28
lines changed

src/socket/index.ts

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import authenticateSocket from './authenticateSocket'
66
import SocketConnection from './socketConnection'
77
import SocketRouter from './router/socketRouter'
88
import { sendMessage } from './messages/socketMessage'
9+
import { logConnection, logConnectionClosed } from './messages/socketLogger'
910

1011
type CloseConnectionOptions = {
1112
code?: number
@@ -16,7 +17,7 @@ type CloseConnectionOptions = {
1617

1718
export default class Socket {
1819
private readonly wss: WebSocketServer
19-
private connections: SocketConnection[] = []
20+
private connections: Map<WebSocket, SocketConnection> = new Map()
2021
private router: SocketRouter
2122

2223
constructor(server: Server, private readonly em: EntityManager) {
@@ -39,32 +40,35 @@ export default class Socket {
3940
return this.wss
4041
}
4142

43+
/* v8 ignore start */
4244
heartbeat(): void {
4345
const interval = setInterval(() => {
44-
this.connections.forEach((conn) => {
45-
/* v8 ignore start */
46+
for (const [ws, conn] of this.connections.entries()) {
4647
if (!conn.alive) {
47-
this.closeConnection(conn.ws, { terminate: true })
48-
return
48+
this.closeConnection(ws, { terminate: true })
49+
continue
4950
}
5051

5152
conn.alive = false
52-
conn.ws.ping()
53-
/* v8 ignore end */
54-
})
53+
ws.ping()
54+
}
5555
}, 30_000)
5656

5757
this.wss.on('close', () => {
5858
clearInterval(interval)
5959
})
6060
}
61+
/* v8 ignore stop */
6162

6263
async handleConnection(ws: WebSocket, req: IncomingMessage): Promise<void> {
64+
logConnection(req)
65+
6366
await RequestContext.create(this.em, async () => {
6467
const key = await authenticateSocket(req.headers?.authorization ?? '')
6568
if (key) {
66-
this.connections.push(new SocketConnection(ws, key, req))
67-
sendMessage(this.connections.at(-1), 'v1.connected', {})
69+
const connection = new SocketConnection(ws, key, req)
70+
this.connections.set(ws, connection)
71+
sendMessage(connection, 'v1.connected', {})
6872
} else {
6973
this.closeConnection(ws)
7074
}
@@ -73,48 +77,54 @@ export default class Socket {
7377

7478
async handleMessage(ws: WebSocket, data: RawData): Promise<void> {
7579
await RequestContext.create(this.em, async () => {
76-
await this.router.handleMessage(this.findConnectionBySocket(ws), data)
80+
const connection = this.connections.get(ws)
81+
if (connection) {
82+
await this.router.handleMessage(connection, data)
83+
/* v8 ignore next 3 */
84+
} else {
85+
this.closeConnection(ws)
86+
}
7787
})
7888
}
7989

8090
/* v8 ignore start */
8191
handlePong(ws: WebSocket): void {
82-
const connection = this.findConnectionBySocket(ws)
92+
const connection = this.findConnection(ws)
8393
if (!connection) return
8494

8595
connection.alive = true
8696
if (connection.rateLimitWarnings > 0) {
8797
connection.rateLimitWarnings--
8898
}
8999
}
90-
/* v8 ignore end */
100+
/* v8 ignore stop */
91101

92102
closeConnection(ws: WebSocket, options: CloseConnectionOptions = {}): void {
93103
const terminate = options.terminate ?? false
94104
const preclosed = options.preclosed ?? false
105+
const code = options.code ?? 3000
95106

107+
/* v8 ignore next 2 */
96108
if (terminate) {
97109
ws.terminate()
98110
} else if (!preclosed) {
99-
ws.close(options.code ?? 3000, options.reason)
111+
ws.close(code, options.reason)
100112
}
101113

102-
this.connections = this.connections.filter((conn) => conn.ws !== ws)
103-
}
114+
const connection = this.findConnection(ws)
115+
/* v8 ignore next */
116+
if (!connection) return
104117

105-
findConnectionBySocket(ws: WebSocket): SocketConnection | undefined {
106-
const connection = this.connections.find((conn) => conn.ws === ws)
107-
/* v8 ignore start */
108-
if (!connection) {
109-
this.closeConnection(ws)
110-
return
111-
}
112-
/* v8 ignore end */
118+
logConnectionClosed(connection, preclosed, code, options.reason)
119+
120+
this.connections.delete(ws)
121+
}
113122

114-
return connection
123+
findConnection(ws: WebSocket): SocketConnection | undefined {
124+
return this.connections.get(ws)
115125
}
116126

117127
findConnections(filter: (conn: SocketConnection) => boolean): SocketConnection[] {
118-
return this.connections.filter(filter)
128+
return Array.from(this.connections.values()).filter(filter)
119129
}
120130
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import SocketConnection from '../socketConnection'
2+
import { SocketMessageResponse } from './socketMessage'
3+
import { IncomingMessage } from 'http'
4+
5+
function getSocketUrl(conn: SocketConnection | undefined): string {
6+
if (!conn) {
7+
return 'WSS /'
8+
}
9+
return `WSS /games/${conn.game.id}/${conn.playerAliasId ? `aliases/${conn.playerAliasId}/` : ''}`
10+
}
11+
12+
function getSize(message: string): string {
13+
return `${Buffer.byteLength(message)}b`
14+
}
15+
16+
export function logRequest(conn: SocketConnection, message: string) {
17+
const req = JSON.parse(message)?.req
18+
console.log(` <-- ${getSocketUrl(conn)}{${req}} ${conn.getRemoteAddress()} ${getSize(message)}`)
19+
}
20+
21+
export function logResponse(conn: SocketConnection, res: SocketMessageResponse, message: string) {
22+
console.log(` --> ${getSocketUrl(conn)}{${res}} ${conn.getRemoteAddress()} ${getSize(message)}`)
23+
}
24+
25+
export function logConnection(req: IncomingMessage) {
26+
console.log(` <-- WSS /open ${req.socket.remoteAddress}`)
27+
}
28+
29+
export function logConnectionClosed(conn: SocketConnection | undefined, preclosed: boolean, code: number, reason?: string) {
30+
const direction = preclosed ? '<--' : '-->'
31+
const ip = conn?.getRemoteAddress() ?? 'unknown'
32+
const displayCode = preclosed ? '' : code
33+
const displayReason = reason ?? ''
34+
console.log(` ${direction} ${getSocketUrl(conn)}close ${ip} ${displayCode} ${displayReason}`.trimEnd())
35+
}

src/socket/messages/socketMessage.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import SocketConnection from '../socketConnection'
2+
import { logResponse } from './socketLogger'
23

34
export const requests = [
45
'v1.players.identify',
@@ -20,10 +21,14 @@ export type SocketMessageResponse = typeof responses[number]
2021

2122
export function sendMessage<T>(conn: SocketConnection, res: SocketMessageResponse, data: T) {
2223
if (conn.ws.readyState === conn.ws.OPEN) {
23-
conn.ws.send(JSON.stringify({
24+
const message = JSON.stringify({
2425
res,
2526
data
26-
}))
27+
})
28+
29+
logResponse(conn, res, message)
30+
31+
conn.ws.send(message)
2732
}
2833
}
2934

src/socket/router/socketRouter.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import SocketError, { sendError } from '../messages/socketError'
99
import { APIKeyScope } from '../../entities/api-key'
1010
import playerListeners from '../listeners/playerListeners'
1111
import gameChannelListeners from '../listeners/gameChannelListeners'
12+
import { logRequest } from '../messages/socketLogger'
1213

1314
const socketMessageValidator = z.object({
1415
req: z.enum(requests),
@@ -26,6 +27,8 @@ export default class SocketRouter {
2627
constructor(readonly socket: Socket) {}
2728

2829
async handleMessage(conn: SocketConnection, rawData: RawData): Promise<void> {
30+
logRequest(conn, rawData.toString())
31+
2932
addBreadcrumb({
3033
category: 'message',
3134
message: rawData.toString(),

src/socket/socketConnection.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export default class SocketConnection {
1616
game: Game | null = null
1717
scopes: APIKeyScope[] = []
1818
private headers: IncomingHttpHeaders = {}
19+
private remoteAddress: string = 'unknown'
1920

2021
rateLimitKey: string = v4()
2122
rateLimitWarnings: number = 0
@@ -24,6 +25,7 @@ export default class SocketConnection {
2425
this.game = apiKey.game
2526
this.scopes = apiKey.scopes
2627
this.headers = req.headers
28+
this.remoteAddress = req.socket.remoteAddress
2729
}
2830

2931
async getPlayerAlias(): Promise<PlayerAlias | null> {
@@ -67,4 +69,8 @@ export default class SocketConnection {
6769

6870
return rateLimitExceeded
6971
}
72+
73+
getRemoteAddress(): string {
74+
return this.remoteAddress
75+
}
7076
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { WebSocket } from 'ws'
2+
import TaloSocket from '../../../src/socket'
3+
import SocketConnection from '../../../src/socket/socketConnection'
4+
import createAPIKeyAndToken from '../../utils/createAPIKeyAndToken'
5+
import { IncomingMessage } from 'http'
6+
import { Socket } from 'net'
7+
import { logConnection, logConnectionClosed, logRequest, logResponse } from '../../../src/socket/messages/socketLogger'
8+
import { EntityManager } from '@mikro-orm/mysql'
9+
10+
describe('Socket logger', () => {
11+
const consoleMock = vi.spyOn(console, 'log').mockImplementation(() => undefined)
12+
13+
afterEach(() => {
14+
consoleMock.mockReset()
15+
})
16+
17+
async function createSocketConnection(): Promise<[TaloSocket, SocketConnection, () => void]>{
18+
const [apiKey] = await createAPIKeyAndToken([])
19+
await (<EntityManager>global.em).persistAndFlush(apiKey)
20+
21+
const socket = new TaloSocket(global.server, global.em)
22+
const conn = new SocketConnection(new WebSocket(null, [], {}), apiKey, new IncomingMessage(new Socket()))
23+
vi.spyOn(conn, 'getRemoteAddress').mockReturnValue('0.0.0.0')
24+
25+
return [
26+
socket,
27+
conn,
28+
() => {
29+
socket.getServer().close()
30+
}
31+
]
32+
}
33+
34+
it('should log requests', async () => {
35+
const [, conn, cleanup] = await createSocketConnection()
36+
37+
logRequest(conn, JSON.stringify({ req: 'v1.fake', data: {} }))
38+
39+
expect(consoleMock).toHaveBeenCalledOnce()
40+
expect(consoleMock).toHaveBeenLastCalledWith(` <-- WSS /games/${conn.game.id}/{v1.fake} 0.0.0.0 27b`)
41+
42+
cleanup()
43+
})
44+
45+
it('should log requests with aliases', async () => {
46+
const [, conn, cleanup] = await createSocketConnection()
47+
conn.playerAliasId = 2
48+
49+
logRequest(conn, JSON.stringify({ req: 'v1.fake', data: {} }))
50+
51+
expect(consoleMock).toHaveBeenCalledOnce()
52+
expect(consoleMock).toHaveBeenLastCalledWith(` <-- WSS /games/${conn.game.id}/aliases/2/{v1.fake} 0.0.0.0 27b`)
53+
54+
cleanup()
55+
})
56+
57+
it('should log responses', async () => {
58+
const [, conn, cleanup] = await createSocketConnection()
59+
60+
logResponse(conn, 'v1.players.identify.success', JSON.stringify({ res: 'v1.players.identify.success', data: {} }))
61+
62+
expect(consoleMock).toHaveBeenCalledOnce()
63+
expect(consoleMock).toHaveBeenLastCalledWith(` --> WSS /games/${conn.game.id}/{v1.players.identify.success} 0.0.0.0 47b`)
64+
65+
cleanup()
66+
})
67+
68+
it('should log connections', async () => {
69+
logConnection(new IncomingMessage(new Socket()))
70+
71+
expect(consoleMock).toHaveBeenCalledOnce()
72+
expect(consoleMock).toHaveBeenLastCalledWith(' <-- WSS /open undefined')
73+
})
74+
75+
it('should log pre-closed connections', async () => {
76+
const [, conn, cleanup] = await createSocketConnection()
77+
78+
logConnectionClosed(conn, true, 3000)
79+
80+
expect(consoleMock).toHaveBeenCalledOnce()
81+
expect(consoleMock).toHaveBeenLastCalledWith(` <-- WSS /games/${conn.game.id}/close 0.0.0.0`)
82+
83+
cleanup()
84+
})
85+
86+
it('should log manually-closed connections', async () => {
87+
const [, conn, cleanup] = await createSocketConnection()
88+
89+
logConnectionClosed(conn, false, 3000, 'Unauthorised')
90+
91+
expect(consoleMock).toHaveBeenCalledOnce()
92+
expect(consoleMock).toHaveBeenLastCalledWith(` --> WSS /games/${conn.game.id}/close 0.0.0.0 3000 Unauthorised`)
93+
94+
cleanup()
95+
})
96+
97+
it('should log manually-closed connections without a reason', async () => {
98+
const [, conn, cleanup] = await createSocketConnection()
99+
100+
logConnectionClosed(conn, false, 3000)
101+
102+
expect(consoleMock).toHaveBeenCalledOnce()
103+
expect(consoleMock).toHaveBeenLastCalledWith(` --> WSS /games/${conn.game.id}/close 0.0.0.0 3000`)
104+
105+
cleanup()
106+
})
107+
108+
it('should log manually-closed connections without a SocketConnection', async () => {
109+
logConnectionClosed(undefined, false, 3000)
110+
111+
expect(consoleMock).toHaveBeenCalledOnce()
112+
expect(consoleMock).toHaveBeenLastCalledWith(' --> WSS /close unknown 3000')
113+
})
114+
115+
it('should log pre-closed connection with aliases', async () => {
116+
const [, conn, cleanup] = await createSocketConnection()
117+
conn.playerAliasId = 2
118+
119+
logConnectionClosed(conn, true, 3000)
120+
121+
expect(consoleMock).toHaveBeenCalledOnce()
122+
expect(consoleMock).toHaveBeenLastCalledWith(` <-- WSS /games/${conn.game.id}/aliases/2/close 0.0.0.0`)
123+
124+
cleanup()
125+
})
126+
})

0 commit comments

Comments
 (0)