• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

project-slippi / slippi-js / 20257242476

16 Dec 2025 05:10AM UTC coverage: 80.865% (+0.05%) from 80.813%
20257242476

push

github

web-flow
refactor: prefer undefined over null (#158)

* find and replace null with undefined

* compare against null non type check for checking existence

* simplify undefined declarations

* update tests

* allow Aerials to still have nullable iasa frame data

* make string types more restrictive

* revert back to null in metadata type

* make aerial frame data undefined

* remove unnecessary undefined in optional types

* fix _write() method prototype

* refer T[] over new Array<T>

* revert some typing changes

* revert

* revert type change

* simplify

* remove some comments

690 of 921 branches covered (74.92%)

Branch coverage included in aggregate %.

70 of 90 new or added lines in 21 files covered. (77.78%)

1 existing line in 1 file now uncovered.

1854 of 2225 relevant lines covered (83.33%)

125903.11 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

10.19
/src/node/console/consoleConnection.ts
1
import net from "net";
12✔
2
import type { Instance } from "reconnect-core";
3

4
import { NETWORK_MESSAGE } from "../../common/utils/slpStream";
12✔
5
import { TypedEventEmitter } from "../../common/utils/typedEventEmitter";
12✔
6
import type { CommunicationMessage } from "./communication";
7
import { CommunicationType, ConsoleCommunication } from "./communication";
12✔
8
import { loadReconnectCoreModule } from "./loadReconnectCoreModule";
12✔
9
import type { Connection, ConnectionDetails, ConnectionEventMap, ConnectionSettings } from "./types";
10
import { ConnectionEvent, ConnectionStatus, Ports } from "./types";
12✔
11

12
const DEFAULT_CONNECTION_TIMEOUT_MS = 20000;
12✔
13

14
enum CommunicationState {
12✔
15
  INITIAL = "initial",
12✔
16
  LEGACY = "legacy",
12✔
17
  NORMAL = "normal",
12✔
18
}
19

20
const defaultConnectionDetails: ConnectionDetails = {
12✔
21
  consoleNick: "unknown",
22
  gameDataCursor: Uint8Array.from([0, 0, 0, 0, 0, 0, 0, 0]),
23
  version: "",
24
  clientToken: 0,
25
};
26

27
const consoleConnectionOptions = {
12✔
28
  autoReconnect: true,
29
};
30

31
export type ConsoleConnectionOptions = typeof consoleConnectionOptions;
32

33
/**
34
 * Responsible for maintaining connection to a Slippi relay connection or Wii connection.
35
 * Events are emitted whenever data is received.
36
 *
37
 * Basic usage example:
38
 *
39
 * ```javascript
40
 * const { ConsoleConnection } = require("@slippi/slippi-js");
41
 *
42
 * const connection = new ConsoleConnection();
43
 * connection.connect("localhost", 667); // You should set these values appropriately
44
 *
45
 * connection.on("data", (data) => {
46
 *   // Received data from console
47
 *   console.log(data);
48
 * });
49
 *
50
 * connection.on("statusChange", (status) => {
51
 *   console.log(`status changed: ${status}`);
52
 * });
53
 * ```
54
 */
55
export class ConsoleConnection extends TypedEventEmitter<ConnectionEventMap> implements Connection {
12✔
56
  private ipAddress: string;
57
  private port: number;
58
  private isRealtime: boolean;
59
  private connectionStatus = ConnectionStatus.DISCONNECTED;
×
60
  private connDetails: ConnectionDetails = { ...defaultConnectionDetails };
×
61
  private client?: net.Socket;
62
  private connection?: Instance<unknown, net.Socket>;
63
  private options: ConsoleConnectionOptions;
64
  private shouldReconnect = false;
×
65

66
  public constructor(options?: Partial<ConsoleConnectionOptions>) {
67
    super();
×
68
    this.ipAddress = "0.0.0.0";
×
69
    this.port = Ports.DEFAULT;
×
70
    this.isRealtime = false;
×
71
    this.options = Object.assign({}, consoleConnectionOptions, options);
×
72
  }
73

74
  /**
75
   * @returns The current connection status.
76
   */
77
  public getStatus(): ConnectionStatus {
78
    return this.connectionStatus;
×
79
  }
80

81
  /**
82
   * @returns The IP address and port of the current connection.
83
   */
84
  public getSettings(): ConnectionSettings {
85
    return {
×
86
      ipAddress: this.ipAddress,
87
      port: this.port,
88
    };
89
  }
90

91
  /**
92
   * @returns The specific details about the connected console.
93
   */
94
  public getDetails(): ConnectionDetails {
95
    return { ...this.connDetails };
×
96
  }
97

98
  /**
99
   * Initiate a connection to the Wii or Slippi relay.
100
   * @param ip   The IP address of the Wii or Slippi relay.
101
   * @param port The port to connect to.
102
   * @param isRealtime Optional. A flag to tell the Wii to send data as quickly as possible
103
   * @param timeout Optional. The timeout in milliseconds when attempting to connect
104
   *                to the Wii or relay.
105
   */
106
  public async connect(
107
    ip: string,
108
    port: number,
109
    isRealtime = false,
×
110
    timeout = DEFAULT_CONNECTION_TIMEOUT_MS,
×
111
  ): Promise<void> {
112
    this.ipAddress = ip;
×
113
    this.port = port;
×
114
    this.isRealtime = isRealtime;
×
115
    await this._connectOnPort(ip, port, timeout);
×
116
  }
117

118
  private async _connectOnPort(ip: string, port: number, timeout: number): Promise<void> {
119
    const inject = await loadReconnectCoreModule();
×
120

121
    // set up reconnect
122
    const reconnect = inject(() =>
×
123
      net.connect({
×
124
        host: ip,
125
        port: port,
126
        timeout: timeout,
127
      }),
128
    );
129

130
    // Indicate we are connecting
131
    this._setStatus(ConnectionStatus.CONNECTING);
×
132

133
    // Prepare console communication obj for talking UBJSON
134
    const consoleComms = new ConsoleCommunication();
×
135

136
    // TODO: reconnect on failed reconnect, not sure how
137
    // TODO: to do this
138
    const connection = reconnect(
×
139
      {
140
        initialDelay: 2000,
141
        maxDelay: 10000,
142
        strategy: "fibonacci",
143
        failAfter: Infinity,
144
      },
145
      (client) => {
146
        this.emit(ConnectionEvent.CONNECT, undefined);
×
147
        // We successfully connected so turn on auto-reconnect
148
        this.shouldReconnect = this.options.autoReconnect;
×
149
        this.client = client;
×
150

151
        let commState: CommunicationState = CommunicationState.INITIAL;
×
152
        client.on("data", (data) => {
×
153
          if (commState === CommunicationState.INITIAL) {
×
154
            commState = this._getInitialCommState(data);
×
155
            console.log(`Connected to ${ip}:${port} with type: ${commState}`);
×
156
            this._setStatus(ConnectionStatus.CONNECTED);
×
157
            console.log(data.toString("hex"));
×
158
          }
159

160
          if (commState === CommunicationState.LEGACY) {
×
161
            // If the first message received was not a handshake message, either we
162
            // connected to an old Nintendont version or a relay instance
163
            this._handleReplayData(data);
×
164
            return;
×
165
          }
166

167
          try {
×
168
            consoleComms.receive(data);
×
169
          } catch (err) {
170
            console.error("Failed to process new data from server...", {
×
171
              error: err,
172
              prevDataBuf: consoleComms.getReceiveBuffer(),
173
              rcvData: data,
174
            });
175
            client.destroy();
×
176
            this.emit(ConnectionEvent.ERROR, err);
×
177
            return;
×
178
          }
179
          const messages = consoleComms.getMessages();
×
180

181
          // Process all of the received messages
182
          try {
×
183
            messages.forEach((message) => this._processMessage(message));
×
184
          } catch (err) {
185
            // Disconnect client to send another handshake message
186
            console.error(err);
×
187
            client.destroy();
×
188
            this.emit(ConnectionEvent.ERROR, err);
×
189
          }
190
        });
191

192
        client.on("timeout", () => {
×
193
          // const previouslyConnected = this.connectionStatus === ConnectionStatus.CONNECTED;
194
          console.warn(`Attempted connection to ${ip}:${port} timed out after ${timeout}ms`);
×
195
          client.destroy();
×
196
        });
197

198
        client.on("end", () => {
×
199
          console.log("disconnect");
×
200
          if (!this.shouldReconnect) {
×
201
            client.destroy();
×
202
          }
203
        });
204

205
        client.on("close", () => {
×
206
          console.log("connection was closed");
×
207
        });
208

209
        const handshakeMsgOut = consoleComms.genHandshakeOut(
×
210
          this.connDetails.gameDataCursor as Uint8Array,
211
          this.connDetails.clientToken ?? 0,
×
212
          this.isRealtime,
213
        );
214

215
        client.write(handshakeMsgOut);
×
216
      },
217
    );
218

219
    const setConnectingStatus = (): void => {
×
220
      // Indicate we are connecting
221
      this._setStatus(this.shouldReconnect ? ConnectionStatus.RECONNECT_WAIT : ConnectionStatus.CONNECTING);
×
222
    };
223

224
    connection.on("connect", setConnectingStatus);
×
225
    connection.on("reconnect", setConnectingStatus);
×
226

227
    connection.on("disconnect", () => {
×
228
      if (!this.shouldReconnect) {
×
229
        connection.reconnect = false;
×
230
        connection.disconnect();
×
231
        this._setStatus(ConnectionStatus.DISCONNECTED);
×
232
      }
233
      // TODO: Figure out how to set RECONNECT_WAIT state here. Currently it will stay on
234
      // TODO: Connecting... forever
235
    });
236

237
    connection.on("error", (err) => {
×
238
      console.warn(`Connection on port ${port} encountered an error.`, err);
×
239

240
      this._setStatus(ConnectionStatus.DISCONNECTED);
×
241
      this.emit(ConnectionEvent.ERROR, `Connection on port ${port} encountered an error.\n${err}`);
×
242
    });
243

244
    this.connection = connection;
×
245
    connection.connect(port);
×
246
  }
247

248
  /**
249
   * Terminate the current connection.
250
   */
251
  public disconnect(): void {
252
    // Prevent reconnections and disconnect
253
    if (this.connection) {
×
254
      this.connection.reconnect = false;
×
255
      this.connection.disconnect();
×
NEW
256
      this.connection = undefined;
×
257
    }
258

259
    if (this.client) {
×
260
      this.client.destroy();
×
261
    }
262
  }
263

264
  private _getInitialCommState(data: Buffer): CommunicationState {
265
    if (data.length < 13) {
×
266
      return CommunicationState.LEGACY;
×
267
    }
268

269
    const openingBytes = Buffer.from([0x7b, 0x69, 0x04, 0x74, 0x79, 0x70, 0x65, 0x55, 0x01]);
×
270

271
    const dataStart = data.slice(4, 13);
×
272

273
    return dataStart.equals(openingBytes) ? CommunicationState.NORMAL : CommunicationState.LEGACY;
×
274
  }
275

276
  private _processMessage(message: CommunicationMessage): void {
277
    this.emit(ConnectionEvent.MESSAGE, message);
×
278
    switch (message.type) {
×
279
      case CommunicationType.KEEP_ALIVE:
280
        // console.log("Keep alive message received");
281

282
        // TODO: This is the jankiest shit ever but it will allow for relay connections not
283
        // TODO: to time out as long as the main connection is still receving keep alive messages
284
        // TODO: Need to figure out a better solution for this. There should be no need to have an
285
        // TODO: active Wii connection for the relay connection to keep itself alive
286
        const fakeKeepAlive = Buffer.from(NETWORK_MESSAGE);
×
287
        this._handleReplayData(fakeKeepAlive);
×
288

289
        break;
×
290
      case CommunicationType.REPLAY:
291
        const readPos = Uint8Array.from(message.payload.pos);
×
292
        const cmp = Buffer.compare(this.connDetails.gameDataCursor as Uint8Array, readPos);
×
293
        if (!message.payload.forcePos && cmp !== 0) {
×
294
          // The readPos is not the one we are waiting on, throw error
295
          throw new Error(
×
296
            `Position of received data is incorrect. Expected: ${this.connDetails.gameDataCursor.toString()}, Received: ${readPos.toString()}`,
297
          );
298
        }
299

300
        if (message.payload.forcePos) {
×
301
          console.warn(
×
302
            "Overflow occured in Nintendont, data has likely been skipped and replay corrupted. " +
303
              "Expected, Received:",
304
            this.connDetails.gameDataCursor,
305
            readPos,
306
          );
307
        }
308

309
        this.connDetails.gameDataCursor = Uint8Array.from(message.payload.nextPos);
×
310

311
        const data = Uint8Array.from(message.payload.data);
×
312
        this._handleReplayData(data);
×
313
        break;
×
314
      case CommunicationType.HANDSHAKE:
315
        const { nick, nintendontVersion } = message.payload;
×
316
        if (nick) {
×
317
          this.connDetails.consoleNick = nick;
×
318
        }
319
        const tokenBuf = Buffer.from(message.payload.clientToken);
×
320
        this.connDetails.clientToken = tokenBuf.readUInt32BE(0);
×
321
        if (nintendontVersion) {
×
322
          this.connDetails.version = nintendontVersion;
×
323
        }
324
        this.connDetails.gameDataCursor = Uint8Array.from(message.payload.pos);
×
325
        this.emit(ConnectionEvent.HANDSHAKE, this.connDetails);
×
326
        break;
×
327
      default:
328
        // Should this be an error?
329
        break;
×
330
    }
331
  }
332

333
  private _handleReplayData(data: Uint8Array): void {
334
    this.emit(ConnectionEvent.DATA, data);
×
335
  }
336

337
  private _setStatus(status: ConnectionStatus): void {
338
    // Don't fire the event if the status hasn't actually changed
339
    if (this.connectionStatus !== status) {
×
340
      this.connectionStatus = status;
×
341
      this.emit(ConnectionEvent.STATUS_CHANGE, this.connectionStatus);
×
342
    }
343
  }
344
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc