@takaro/gameserver 0.0.0-next.0da151e

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +36 -0
  2. package/dist/TakaroEmitter.d.ts +30 -0
  3. package/dist/TakaroEmitter.d.ts.map +1 -0
  4. package/dist/TakaroEmitter.js +101 -0
  5. package/dist/TakaroEmitter.js.map +1 -0
  6. package/dist/config.d.ts +9 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +13 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/gameservers/7d2d/apiResponses.d.ts +194 -0
  11. package/dist/gameservers/7d2d/apiResponses.d.ts.map +1 -0
  12. package/dist/gameservers/7d2d/apiResponses.js +2 -0
  13. package/dist/gameservers/7d2d/apiResponses.js.map +1 -0
  14. package/dist/gameservers/7d2d/connectionInfo.d.ts +38 -0
  15. package/dist/gameservers/7d2d/connectionInfo.d.ts.map +1 -0
  16. package/dist/gameservers/7d2d/connectionInfo.js +66 -0
  17. package/dist/gameservers/7d2d/connectionInfo.js.map +1 -0
  18. package/dist/gameservers/7d2d/emitter.d.ts +32 -0
  19. package/dist/gameservers/7d2d/emitter.d.ts.map +1 -0
  20. package/dist/gameservers/7d2d/emitter.js +273 -0
  21. package/dist/gameservers/7d2d/emitter.js.map +1 -0
  22. package/dist/gameservers/7d2d/index.d.ts +34 -0
  23. package/dist/gameservers/7d2d/index.d.ts.map +1 -0
  24. package/dist/gameservers/7d2d/index.js +304 -0
  25. package/dist/gameservers/7d2d/index.js.map +1 -0
  26. package/dist/gameservers/7d2d/itemWorker.d.ts +2 -0
  27. package/dist/gameservers/7d2d/itemWorker.d.ts.map +1 -0
  28. package/dist/gameservers/7d2d/itemWorker.js +36 -0
  29. package/dist/gameservers/7d2d/itemWorker.js.map +1 -0
  30. package/dist/gameservers/7d2d/items-7d2d.json +25051 -0
  31. package/dist/gameservers/7d2d/sdtdAPIClient.d.ts +19 -0
  32. package/dist/gameservers/7d2d/sdtdAPIClient.d.ts.map +1 -0
  33. package/dist/gameservers/7d2d/sdtdAPIClient.js +57 -0
  34. package/dist/gameservers/7d2d/sdtdAPIClient.js.map +1 -0
  35. package/dist/gameservers/generic/connectionInfo.d.ts +16 -0
  36. package/dist/gameservers/generic/connectionInfo.d.ts.map +1 -0
  37. package/dist/gameservers/generic/connectionInfo.js +29 -0
  38. package/dist/gameservers/generic/connectionInfo.js.map +1 -0
  39. package/dist/gameservers/generic/connectorClient.d.ts +7 -0
  40. package/dist/gameservers/generic/connectorClient.d.ts.map +1 -0
  41. package/dist/gameservers/generic/connectorClient.js +60 -0
  42. package/dist/gameservers/generic/connectorClient.js.map +1 -0
  43. package/dist/gameservers/generic/emitter.d.ts +13 -0
  44. package/dist/gameservers/generic/emitter.d.ts.map +1 -0
  45. package/dist/gameservers/generic/emitter.js +31 -0
  46. package/dist/gameservers/generic/emitter.js.map +1 -0
  47. package/dist/gameservers/generic/index.d.ts +36 -0
  48. package/dist/gameservers/generic/index.d.ts.map +1 -0
  49. package/dist/gameservers/generic/index.js +170 -0
  50. package/dist/gameservers/generic/index.js.map +1 -0
  51. package/dist/gameservers/rust/connectionInfo.d.ts +29 -0
  52. package/dist/gameservers/rust/connectionInfo.d.ts.map +1 -0
  53. package/dist/gameservers/rust/connectionInfo.js +51 -0
  54. package/dist/gameservers/rust/connectionInfo.js.map +1 -0
  55. package/dist/gameservers/rust/emitter.d.ts +32 -0
  56. package/dist/gameservers/rust/emitter.d.ts.map +1 -0
  57. package/dist/gameservers/rust/emitter.js +160 -0
  58. package/dist/gameservers/rust/emitter.js.map +1 -0
  59. package/dist/gameservers/rust/index.d.ts +35 -0
  60. package/dist/gameservers/rust/index.d.ts.map +1 -0
  61. package/dist/gameservers/rust/index.js +217 -0
  62. package/dist/gameservers/rust/index.js.map +1 -0
  63. package/dist/gameservers/rust/items-rust.json +20771 -0
  64. package/dist/getGame.d.ts +10 -0
  65. package/dist/getGame.d.ts.map +1 -0
  66. package/dist/getGame.js +27 -0
  67. package/dist/getGame.js.map +1 -0
  68. package/dist/interfaces/GameServer.d.ts +99 -0
  69. package/dist/interfaces/GameServer.d.ts.map +1 -0
  70. package/dist/interfaces/GameServer.js +235 -0
  71. package/dist/interfaces/GameServer.js.map +1 -0
  72. package/dist/main.d.ts +12 -0
  73. package/dist/main.d.ts.map +1 -0
  74. package/dist/main.js +12 -0
  75. package/dist/main.js.map +1 -0
  76. package/package.json +16 -0
  77. package/src/TakaroEmitter.ts +146 -0
  78. package/src/TakaroEmitter.unit.test.ts +201 -0
  79. package/src/__tests__/gameEventEmitter.test.ts +29 -0
  80. package/src/config.ts +20 -0
  81. package/src/gameservers/7d2d/__tests__/7d2dActions.unit.test.ts +96 -0
  82. package/src/gameservers/7d2d/__tests__/7d2dEventDetection.unit.test.ts +424 -0
  83. package/src/gameservers/7d2d/apiResponses.ts +213 -0
  84. package/src/gameservers/7d2d/connectionInfo.ts +46 -0
  85. package/src/gameservers/7d2d/emitter.ts +334 -0
  86. package/src/gameservers/7d2d/emitter.unit.test.ts +117 -0
  87. package/src/gameservers/7d2d/index.ts +367 -0
  88. package/src/gameservers/7d2d/itemWorker.ts +41 -0
  89. package/src/gameservers/7d2d/items-7d2d.json +25051 -0
  90. package/src/gameservers/7d2d/sdtdAPIClient.ts +82 -0
  91. package/src/gameservers/generic/connectionInfo.ts +19 -0
  92. package/src/gameservers/generic/connectorClient.ts +73 -0
  93. package/src/gameservers/generic/emitter.ts +36 -0
  94. package/src/gameservers/generic/index.ts +193 -0
  95. package/src/gameservers/rust/__tests__/rustActions.unit.test.ts +141 -0
  96. package/src/gameservers/rust/connectionInfo.ts +35 -0
  97. package/src/gameservers/rust/emitter.ts +198 -0
  98. package/src/gameservers/rust/emitter.unit.test.ts +95 -0
  99. package/src/gameservers/rust/index.ts +270 -0
  100. package/src/gameservers/rust/items-rust.json +20771 -0
  101. package/src/getGame.ts +34 -0
  102. package/src/interfaces/GameServer.ts +215 -0
  103. package/src/main.ts +16 -0
  104. package/tsconfig.build.json +9 -0
  105. package/tsconfig.json +9 -0
  106. package/typedoc.json +3 -0
@@ -0,0 +1,334 @@
1
+ import { logger } from '@takaro/util';
2
+ import EventSource from 'eventsource';
3
+ import { JsonObject } from 'type-fest';
4
+ import {
5
+ ChatChannel,
6
+ EventChatMessage,
7
+ EventEntityKilled,
8
+ EventLogLine,
9
+ EventPlayerConnected,
10
+ EventPlayerDeath,
11
+ EventPlayerDisconnected,
12
+ GameEvents,
13
+ IGamePlayer,
14
+ } from '@takaro/modules';
15
+ import { SdtdConnectionInfo } from './connectionInfo.js';
16
+ import { TakaroEmitter } from '../../TakaroEmitter.js';
17
+ import { SevenDaysToDie } from './index.js';
18
+ import ms from 'ms';
19
+
20
+ interface I7DaysToDieEvent extends JsonObject {
21
+ msg: string;
22
+ }
23
+
24
+ /**
25
+ * 7d2d servers can get really spammy with bugged vehicles, buggy mods, etc.
26
+ * This is a list of messages that we don't want to emit events for.
27
+ */
28
+ const blackListedMessages = [
29
+ 'NullReferenceException',
30
+ 'Infinity or NaN floating point numbers appear when calculating the transform matrix for a Collider',
31
+ 'IsMovementBlocked',
32
+ 'Particle System is trying to spawn on a mesh with zero surface area',
33
+ 'AddDecorationAt',
34
+ 'EntityFactory CreateEntity: unknown type',
35
+ 'DroneManager',
36
+ 'VehicleManager',
37
+ 'kinematic body',
38
+ ];
39
+
40
+ const EventRegexMap = {
41
+ [GameEvents.PLAYER_CONNECTED]:
42
+ /PlayerSpawnedInWorld \(reason: (JoinMultiplayer|EnterMultiplayer), position: [-\d]+, [-\d]+, [-\d]+\): EntityID=(?<entityId>[-\d]+), PltfmId='(Steam|XBL)_[\w\d]+', CrossId='EOS_[\w\d]+', OwnerID='(Steam|XBL)_\d+', PlayerName='(?<name>.+)'/,
43
+ [GameEvents.PLAYER_DISCONNECTED]: /(Player disconnected: )/,
44
+ [GameEvents.CHAT_MESSAGE]:
45
+ /Chat \(from '(?<platformId>[\w\d-]+)', entity id '(?<entityId>[-\d]+)', to '(?<channel>\w+)'\): ('(?<playerName>.+)':)?(?<message>.+)/,
46
+ [GameEvents.PLAYER_DEATH]:
47
+ /GMSG: Player '(?<name1>.+)' died|\[(?:CSMM_Patrons|PrismaCore)\]playerDied: (?<name2>.+) \((?<steamOrXboxId>.+)\) died @ (?<xCoord>[-\d]+) (?<yCoord>[-\d]+) (?<zCoord>[-\d]+)/,
48
+ [GameEvents.ENTITY_KILLED]:
49
+ /\[(?:CSMM_Patrons|PrismaCore)\]entityKilled: (?<killerName>.+) \((?<steamOrXboxId>.+)\) killed (?<entityType>\w+) (?<entityName>[\w\s\u00C0-\u024F\[\]-]+) with (?<weapon>.+)|Entity (?<entityName2>[\w\s\u00C0-\u024F]+) \d+ killed by (?<killerName2>.+) \d+/,
50
+ };
51
+
52
+ export class SevenDaysToDieEmitter extends TakaroEmitter {
53
+ private SSERegex = /\d+-\d+-\d+T\d+:\d+:\d+ \d+\.\d+ INF (.+)/;
54
+ private eventSource!: EventSource;
55
+ private logger = logger('7D2D:SSE');
56
+ private sdtd: SevenDaysToDie;
57
+
58
+ private recentMessages: Set<string> = new Set(); // To track recent messages
59
+ private checkInterval: NodeJS.Timeout;
60
+ private lastMessageTimestamp = Date.now();
61
+ private keepAliveTimeout = ms('5minutes');
62
+ private boundListener = (data: MessageEvent) => this.listener(data);
63
+
64
+ constructor(private config: SdtdConnectionInfo) {
65
+ super();
66
+ this.sdtd = new SevenDaysToDie(config, {});
67
+ }
68
+
69
+ private isModdedFormat(msg: string): boolean {
70
+ return msg.includes('[CSMM_Patrons]') || msg.includes('[PrismaCore]');
71
+ }
72
+
73
+ get url() {
74
+ if (this.config.useLegacy) {
75
+ return `${this.config.useTls ? 'https' : 'http'}://${this.config.host}/sse/log`;
76
+ }
77
+ return `${this.config.useTls ? 'https' : 'http'}://${this.config.host}/sse/?events=log`;
78
+ }
79
+
80
+ async start(): Promise<void> {
81
+ this.checkInterval = setInterval(() => {
82
+ if (Date.now() - this.lastMessageTimestamp >= this.keepAliveTimeout) {
83
+ this.logger.warn(`No messages received for ${ms(this.keepAliveTimeout, { long: true })}. Reconnecting...`);
84
+ this.lastMessageTimestamp = Date.now();
85
+ this.stop()
86
+ .then(() => this.start())
87
+ .catch((err) => this.logger.error('Error during reconnection', err));
88
+ }
89
+ }, 5000);
90
+
91
+ await Promise.race([
92
+ new Promise<void>((resolve, reject) => {
93
+ this.logger.debug(`Connecting to ${this.config.host}`);
94
+ this.eventSource = new EventSource(this.url, {
95
+ headers: {
96
+ 'X-SDTD-API-TOKENNAME': this.config.adminUser,
97
+ 'X-SDTD-API-SECRET': this.config.adminToken,
98
+ },
99
+ });
100
+
101
+ this.eventSource.addEventListener('logLine', this.boundListener);
102
+
103
+ this.eventSource.onerror = (e) => {
104
+ this.logger.error('Event source error', e);
105
+ return reject(e);
106
+ };
107
+ this.eventSource.onopen = () => {
108
+ this.logger.debug('Opened a SSE channel for server');
109
+ return resolve();
110
+ };
111
+ }),
112
+ new Promise((_resolve, reject) => {
113
+ setTimeout(() => {
114
+ reject(new Error('Timed out'));
115
+ }, 30000);
116
+ }),
117
+ ]);
118
+ }
119
+
120
+ async stop(): Promise<void> {
121
+ if (this.checkInterval) {
122
+ clearInterval(this.checkInterval);
123
+ }
124
+ this.eventSource.removeEventListener('logLine', this.boundListener);
125
+ this.eventSource.close();
126
+ }
127
+
128
+ async parseMessage(logLine: I7DaysToDieEvent) {
129
+ this.logger.silly(`Received message from game server: ${logLine.msg}`);
130
+ if (!logLine.msg || typeof logLine.msg !== 'string') {
131
+ throw new Error('Invalid logLine');
132
+ }
133
+
134
+ if (blackListedMessages.some((msg) => logLine.msg.includes(msg))) {
135
+ return;
136
+ }
137
+
138
+ if (EventRegexMap[GameEvents.PLAYER_CONNECTED].test(logLine.msg)) {
139
+ const data = await this.handlePlayerConnected(logLine);
140
+ await this.emit(GameEvents.PLAYER_CONNECTED, data);
141
+ }
142
+
143
+ if (EventRegexMap[GameEvents.PLAYER_DISCONNECTED].test(logLine.msg)) {
144
+ const data = await this.handlePlayerDisconnected(logLine);
145
+ await this.emit(GameEvents.PLAYER_DISCONNECTED, data);
146
+ }
147
+
148
+ if (EventRegexMap[GameEvents.CHAT_MESSAGE].test(logLine.msg)) {
149
+ const data = await this.handleChatMessage(logLine);
150
+ if (data) await this.emit(GameEvents.CHAT_MESSAGE, data);
151
+ }
152
+
153
+ if (EventRegexMap[GameEvents.PLAYER_DEATH].test(logLine.msg)) {
154
+ const data = await this.handlePlayerDeath(logLine);
155
+ if (data) await this.emit(GameEvents.PLAYER_DEATH, data);
156
+ }
157
+
158
+ if (EventRegexMap[GameEvents.ENTITY_KILLED].test(logLine.msg)) {
159
+ const data = await this.handleEntityKilled(logLine);
160
+ if (data) await this.emit(GameEvents.ENTITY_KILLED, data);
161
+ }
162
+
163
+ await this.emit(
164
+ GameEvents.LOG_LINE,
165
+ new EventLogLine({
166
+ msg: logLine.msg,
167
+ }),
168
+ );
169
+ }
170
+
171
+ private async handlePlayerConnected(logLine: I7DaysToDieEvent) {
172
+ const nameMatches = /PlayerName='([^']+)/.exec(logLine.msg);
173
+ const platformIdMatches = /PltfmId='(.+)', CrossId=/.exec(logLine.msg);
174
+ const crossIdMatches = /CrossId='(.+)', OwnerID/.exec(logLine.msg);
175
+
176
+ const name = nameMatches ? nameMatches[1] : 'Unknown name';
177
+ const platformId = platformIdMatches ? platformIdMatches[1] : null;
178
+ const epicOnlineServicesId = crossIdMatches ? crossIdMatches[1].replace('EOS_', '') : undefined;
179
+ const gameId = epicOnlineServicesId;
180
+
181
+ const steamId = platformId && platformId.startsWith('Steam_') ? platformId.replace('Steam_', '') : undefined;
182
+ const xboxLiveId = platformId && platformId.startsWith('XBL_') ? platformId.replace('XBL_', '') : undefined;
183
+
184
+ if (!gameId) throw new Error('Could not find gameId');
185
+
186
+ return new EventPlayerConnected({
187
+ msg: logLine.msg,
188
+ player: new IGamePlayer({
189
+ name,
190
+ gameId,
191
+ steamId,
192
+ xboxLiveId,
193
+ epicOnlineServicesId,
194
+ }),
195
+ });
196
+ }
197
+ private async handlePlayerDisconnected(logLine: I7DaysToDieEvent) {
198
+ const nameMatch = /PlayerName='([^']+)/.exec(logLine.msg);
199
+ const platformIdMatches = /PltfmId='(.+)', CrossId=/.exec(logLine.msg);
200
+ const crossIdMatches = /CrossId='(.+)', OwnerID/.exec(logLine.msg);
201
+
202
+ const name = nameMatch ? nameMatch[1] : 'Unknown name';
203
+ const platformId = platformIdMatches ? platformIdMatches[1] : null;
204
+
205
+ const steamId = platformId && platformId.startsWith('Steam_') ? platformId.replace('Steam_', '') : undefined;
206
+ const xboxLiveId = platformId && platformId.startsWith('XBL_') ? platformId.replace('XBL_', '') : undefined;
207
+ const epicOnlineServicesId = crossIdMatches ? crossIdMatches[1].replace('EOS_', '') : undefined;
208
+ const gameId = epicOnlineServicesId;
209
+
210
+ if (!gameId) throw new Error('Could not find gameId');
211
+
212
+ return new EventPlayerDisconnected({
213
+ msg: logLine.msg,
214
+ player: new IGamePlayer({
215
+ name,
216
+ gameId,
217
+ steamId,
218
+ xboxLiveId,
219
+ }),
220
+ });
221
+ }
222
+
223
+ private async handleChatMessage(logLine: I7DaysToDieEvent) {
224
+ const match = EventRegexMap[GameEvents.CHAT_MESSAGE].exec(logLine.msg);
225
+ if (!match) throw new Error('Could not parse chat message');
226
+
227
+ const { groups } = match;
228
+ if (!groups) throw new Error('Could not parse chat message');
229
+
230
+ const { platformId, name, message, channel } = groups;
231
+
232
+ if (platformId === '-non-player-' && name !== 'Server') {
233
+ return;
234
+ }
235
+
236
+ const trimmedMessage = message.trim();
237
+ if (this.recentMessages.has(trimmedMessage)) {
238
+ return; // Ignore if recently processed
239
+ }
240
+ this.recentMessages.add(trimmedMessage);
241
+ setTimeout(() => this.recentMessages.delete(trimmedMessage), 1000);
242
+
243
+ const xboxLiveId = platformId.startsWith('XBL_') ? platformId.replace('XBL_', '') : undefined;
244
+ const steamId = platformId.startsWith('Steam_') ? platformId.replace('Steam_', '') : undefined;
245
+
246
+ if (steamId || xboxLiveId) {
247
+ const id = steamId || xboxLiveId || '';
248
+ const player = await this.sdtd.steamIdOrXboxToGameId(id);
249
+
250
+ let detectedChannel: ChatChannel = ChatChannel.GLOBAL;
251
+
252
+ switch (channel) {
253
+ case 'Global':
254
+ detectedChannel = ChatChannel.GLOBAL;
255
+ break;
256
+ case 'Party':
257
+ detectedChannel = ChatChannel.TEAM;
258
+ break;
259
+ case 'Friends':
260
+ detectedChannel = ChatChannel.FRIENDS;
261
+ break;
262
+ default:
263
+ break;
264
+ }
265
+
266
+ if (player) {
267
+ return new EventChatMessage({
268
+ player,
269
+ channel: detectedChannel,
270
+ msg: trimmedMessage.trim(),
271
+ });
272
+ }
273
+ }
274
+ }
275
+
276
+ private async handlePlayerDeath(logLine: I7DaysToDieEvent) {
277
+ if (this.isModdedFormat(logLine.msg) && !this.config.useCPM) return;
278
+ if (logLine.msg.includes('GMSG') && this.config.useCPM) return;
279
+
280
+ const match = EventRegexMap[GameEvents.PLAYER_DEATH].exec(logLine.msg);
281
+ if (!match) throw new Error('Could not parse player death message');
282
+ const { groups } = match;
283
+ if (!groups) throw new Error('Could not parse player death message');
284
+
285
+ const { xCoord, yCoord, zCoord, steamOrXboxId } = groups;
286
+
287
+ const player = await this.sdtd.steamIdOrXboxToGameId(steamOrXboxId);
288
+
289
+ return new EventPlayerDeath({
290
+ msg: logLine.msg,
291
+ player,
292
+ position: {
293
+ x: parseFloat(xCoord),
294
+ y: parseFloat(yCoord),
295
+ z: parseFloat(zCoord),
296
+ },
297
+ });
298
+ }
299
+
300
+ private async handleEntityKilled(logLine: I7DaysToDieEvent) {
301
+ if (this.isModdedFormat(logLine.msg) && !this.config.useCPM) return;
302
+ if (logLine.msg.includes('killed by') && this.config.useCPM) return;
303
+
304
+ const match = EventRegexMap[GameEvents.ENTITY_KILLED].exec(logLine.msg);
305
+ if (!match) throw new Error('Could not parse entity killed message');
306
+ const { groups } = match;
307
+ if (!groups) throw new Error('Could not parse entity killed message');
308
+
309
+ // Extracting the relevant details from the named groups
310
+ const { entityName, entityName2, weapon, steamOrXboxId } = groups;
311
+
312
+ const player = await this.sdtd.steamIdOrXboxToGameId(steamOrXboxId);
313
+
314
+ // Constructing the EventEntityKilled object with the parsed data
315
+ return new EventEntityKilled({
316
+ msg: logLine.msg,
317
+ entity: entityName || entityName2,
318
+ player,
319
+ weapon: weapon || undefined, // Assuming that 'weapon' might not be present in some log lines
320
+ });
321
+ }
322
+
323
+ async listener(data: MessageEvent) {
324
+ this.lastMessageTimestamp = Date.now();
325
+
326
+ const parsed = JSON.parse(data.data);
327
+ const messageMatch = this.SSERegex.exec(parsed.msg);
328
+ if (messageMatch && messageMatch[1]) {
329
+ parsed.msg = messageMatch[1];
330
+ }
331
+
332
+ await this.parseMessage(parsed);
333
+ }
334
+ }
@@ -0,0 +1,117 @@
1
+ import 'reflect-metadata';
2
+ import { SevenDaysToDieEmitter } from './emitter.js';
3
+ import { SdtdConnectionInfo } from './connectionInfo.js';
4
+ import { SevenDaysToDie } from './index.js';
5
+ import { expect, sandbox } from '@takaro/test';
6
+ import { describe, it, beforeEach, afterEach } from 'node:test';
7
+ import EventSource from 'eventsource';
8
+
9
+ describe('SevenDaysToDieEmitter', () => {
10
+ let emitter: SevenDaysToDieEmitter;
11
+
12
+ beforeEach(() => {
13
+ // Stub the SevenDaysToDie constructor to prevent network calls
14
+ sandbox.stub(SevenDaysToDie.prototype, 'steamIdOrXboxToGameId').resolves(undefined);
15
+
16
+ emitter = new SevenDaysToDieEmitter(
17
+ new SdtdConnectionInfo({
18
+ host: 'localhost:8080',
19
+ adminUser: 'test',
20
+ adminToken: 'test',
21
+ useTls: false,
22
+ useLegacy: false,
23
+ useCPM: false,
24
+ }),
25
+ );
26
+ // Add error handler to prevent unhandled errors
27
+ emitter.on('error', () => {
28
+ // Ignore errors in tests
29
+ });
30
+ });
31
+
32
+ afterEach(async () => {
33
+ if (emitter) {
34
+ try {
35
+ await emitter.stop();
36
+ } catch {
37
+ // Ignore errors during cleanup
38
+ }
39
+ }
40
+ sandbox.restore();
41
+ });
42
+
43
+ it.skip('Does not accumulate listeners on start/stop/start cycle', async () => {
44
+ // Track listeners
45
+ const listeners: Array<(data: any) => void> = [];
46
+
47
+ // Create a mock EventSource
48
+ class MockEventSource {
49
+ onerror: any = null;
50
+ onopen: any = null;
51
+
52
+ constructor(..._args: any[]) {
53
+ //No-op constructor - onopen will be set by the emitter
54
+ }
55
+
56
+ addEventListener(event: string, listener: any) {
57
+ listeners.push(listener);
58
+ }
59
+
60
+ removeEventListener(event: string, listener: any) {
61
+ const index = listeners.indexOf(listener);
62
+ if (index > -1) {
63
+ listeners.splice(index, 1);
64
+ }
65
+ }
66
+
67
+ close() {
68
+ // Mock close
69
+ }
70
+ }
71
+
72
+ // Stub EventSource constructor
73
+ const OriginalEventSource = EventSource;
74
+ const MockEventSourceClass = class extends MockEventSource {
75
+ constructor(...args: any[]) {
76
+ super(...args);
77
+ // Immediately trigger onopen to resolve the Promise.race in start()
78
+ const triggerOpen = () => {
79
+ if (this.onopen) {
80
+ this.onopen();
81
+ }
82
+ };
83
+ queueMicrotask(triggerOpen.bind(this));
84
+ }
85
+ };
86
+ (global as any).EventSource = MockEventSourceClass;
87
+
88
+ try {
89
+ // First start
90
+ await emitter.start();
91
+ expect(listeners.length).to.equal(1, 'Should have 1 listener after start');
92
+
93
+ // Stop
94
+ await emitter.stop();
95
+ expect(listeners.length).to.equal(0, 'Should have 0 listeners after stop');
96
+
97
+ // Second start (this is where the bug would cause accumulation)
98
+ await emitter.start();
99
+ expect(listeners.length).to.equal(1, 'Should have 1 listener after restart (not 2)');
100
+
101
+ // Third start/stop cycle to be extra sure
102
+ await emitter.stop();
103
+ expect(listeners.length).to.equal(0, 'Should have 0 listeners after second stop');
104
+
105
+ await emitter.start();
106
+ expect(listeners.length).to.equal(1, 'Should have 1 listener after second restart (not 2 or 3)');
107
+ } finally {
108
+ // Restore EventSource
109
+ (global as any).EventSource = OriginalEventSource;
110
+ }
111
+ });
112
+
113
+ it('Uses the same function reference for addEventListener and removeEventListener', () => {
114
+ // Verify boundListener is defined as a class field
115
+ expect((emitter as any).boundListener).to.be.a('function');
116
+ });
117
+ });