@couch-kit/host 0.3.0 → 0.4.0

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.
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "@couch-kit/host",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
+ "description": "React Native host for local multiplayer party games on Android TV — WebSocket server, state management, and static file serving",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/faluciano/react-native-couch-kit.git",
9
+ "directory": "packages/host"
10
+ },
11
+ "homepage": "https://github.com/faluciano/react-native-couch-kit#readme",
12
+ "keywords": [
13
+ "react-native",
14
+ "android-tv",
15
+ "fire-tv",
16
+ "party-game",
17
+ "websocket",
18
+ "couch-kit",
19
+ "local-multiplayer",
20
+ "game-host"
21
+ ],
4
22
  "main": "src/index.tsx",
5
23
  "react-native": "src/index.tsx",
6
24
  "source": "src/index.tsx",
@@ -25,7 +43,7 @@
25
43
  "clean": "del-cli lib"
26
44
  },
27
45
  "dependencies": {
28
- "@couch-kit/core": "0.2.0",
46
+ "@couch-kit/core": "0.3.0",
29
47
  "js-sha1": "^0.7.0",
30
48
  "react-native-fs": "^2.20.0",
31
49
  "react-native-network-info": "^5.2.1",
@@ -1,15 +1,32 @@
1
1
  /**
2
- * Lightweight EventEmitter implementation for cross-platform compatibility.
2
+ * Lightweight, type-safe EventEmitter implementation for cross-platform compatibility.
3
3
  * Works in browser, React Native, and Node.js environments.
4
+ *
5
+ * @typeParam EventMap - A record mapping event names to their listener argument tuples.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * type MyEvents = {
10
+ * data: [payload: string];
11
+ * error: [err: Error];
12
+ * close: [];
13
+ * };
14
+ * const emitter = new EventEmitter<MyEvents>();
15
+ * emitter.on("data", (payload) => { ... }); // payload is typed as string
16
+ * ```
4
17
  */
5
18
 
6
19
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
- type Listener = (...args: any[]) => void;
20
+ export class EventEmitter<
21
+ EventMap extends Record<string, any[]> = Record<string, any[]>,
22
+ > {
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ private listeners: Map<string, Array<(...args: any[]) => void>> = new Map();
8
25
 
9
- export class EventEmitter {
10
- private listeners: Map<string, Listener[]> = new Map();
11
-
12
- on(event: string, listener: Listener): this {
26
+ on<K extends string & keyof EventMap>(
27
+ event: K,
28
+ listener: (...args: EventMap[K]) => void,
29
+ ): this {
13
30
  if (!this.listeners.has(event)) {
14
31
  this.listeners.set(event, []);
15
32
  }
@@ -17,15 +34,22 @@ export class EventEmitter {
17
34
  return this;
18
35
  }
19
36
 
20
- once(event: string, listener: Listener): this {
21
- const onceWrapper: Listener = (...args: any[]) => {
22
- this.off(event, onceWrapper);
23
- listener(...args);
37
+ once<K extends string & keyof EventMap>(
38
+ event: K,
39
+ listener: (...args: EventMap[K]) => void,
40
+ ): this {
41
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
+ const onceWrapper = (...args: any[]) => {
43
+ this.off(event, onceWrapper as (...a: EventMap[K]) => void);
44
+ listener(...(args as EventMap[K]));
24
45
  };
25
- return this.on(event, onceWrapper);
46
+ return this.on(event, onceWrapper as (...a: EventMap[K]) => void);
26
47
  }
27
48
 
28
- off(event: string, listener: Listener): this {
49
+ off<K extends string & keyof EventMap>(
50
+ event: K,
51
+ listener: (...args: EventMap[K]) => void,
52
+ ): this {
29
53
  const listeners = this.listeners.get(event);
30
54
  if (listeners) {
31
55
  const index = listeners.indexOf(listener);
@@ -36,7 +60,10 @@ export class EventEmitter {
36
60
  return this;
37
61
  }
38
62
 
39
- emit(event: string, ...args: any[]): boolean {
63
+ emit<K extends string & keyof EventMap>(
64
+ event: K,
65
+ ...args: EventMap[K]
66
+ ): boolean {
40
67
  const listeners = this.listeners.get(event);
41
68
  if (!listeners || listeners.length === 0) {
42
69
  return false;
@@ -54,7 +81,7 @@ export class EventEmitter {
54
81
  return true;
55
82
  }
56
83
 
57
- removeAllListeners(event?: string): this {
84
+ removeAllListeners<K extends string & keyof EventMap>(event?: K): this {
58
85
  if (event) {
59
86
  this.listeners.delete(event);
60
87
  } else {
@@ -63,7 +90,7 @@ export class EventEmitter {
63
90
  return this;
64
91
  }
65
92
 
66
- listenerCount(event: string): number {
93
+ listenerCount<K extends string & keyof EventMap>(event: K): number {
67
94
  return this.listeners.get(event)?.length ?? 0;
68
95
  }
69
96
  }
package/src/provider.tsx CHANGED
@@ -2,6 +2,7 @@ import React, {
2
2
  createContext,
3
3
  useContext,
4
4
  useEffect,
5
+ useMemo,
5
6
  useReducer,
6
7
  useRef,
7
8
  } from "react";
@@ -9,25 +10,35 @@ import { GameWebSocketServer } from "./websocket";
9
10
  import { useStaticServer } from "./server";
10
11
  import {
11
12
  MessageTypes,
13
+ InternalActionTypes,
14
+ DEFAULT_HTTP_PORT,
15
+ DEFAULT_WS_PORT_OFFSET,
12
16
  type IGameState,
13
17
  type IAction,
18
+ type InternalAction,
14
19
  type ClientMessage,
15
20
  } from "@couch-kit/core";
16
21
 
17
- interface GameHostConfig<S extends IGameState, A extends IAction> {
22
+ export interface GameHostConfig<S extends IGameState, A extends IAction> {
18
23
  initialState: S;
19
- reducer: (state: S, action: A) => S;
24
+ reducer: (state: S, action: A | InternalAction<S>) => S;
20
25
  port?: number; // Static server port (default 8080)
21
26
  wsPort?: number; // WebSocket port (default: HTTP port + 2, i.e. 8082)
22
27
  devMode?: boolean;
23
28
  devServerUrl?: string;
24
29
  staticDir?: string; // Override the default www directory path (required on Android)
25
30
  debug?: boolean;
31
+ /** Called when a player successfully joins. */
32
+ onPlayerJoined?: (playerId: string, name: string) => void;
33
+ /** Called when a player disconnects. */
34
+ onPlayerLeft?: (playerId: string) => void;
35
+ /** Called when a server error occurs. */
36
+ onError?: (error: Error) => void;
26
37
  }
27
38
 
28
39
  interface GameHostContextValue<S extends IGameState, A extends IAction> {
29
40
  state: S;
30
- dispatch: (action: A) => void;
41
+ dispatch: (action: A | InternalAction<S>) => void;
31
42
  serverUrl: string | null;
32
43
  serverError: Error | null;
33
44
  }
@@ -38,6 +49,42 @@ const GameHostContext = createContext<GameHostContextValue<any, any> | null>(
38
49
  null,
39
50
  );
40
51
 
52
+ /**
53
+ * Validates that an incoming message has the expected shape.
54
+ * Returns true if the message is a valid ClientMessage, false otherwise.
55
+ */
56
+ function isValidClientMessage(msg: unknown): msg is ClientMessage {
57
+ if (typeof msg !== "object" || msg === null) return false;
58
+ const m = msg as Record<string, unknown>;
59
+ if (typeof m.type !== "string") return false;
60
+
61
+ switch (m.type) {
62
+ case MessageTypes.JOIN:
63
+ return (
64
+ typeof m.payload === "object" &&
65
+ m.payload !== null &&
66
+ typeof (m.payload as Record<string, unknown>).name === "string"
67
+ );
68
+ case MessageTypes.ACTION:
69
+ return (
70
+ typeof m.payload === "object" &&
71
+ m.payload !== null &&
72
+ typeof (m.payload as Record<string, unknown>).type === "string"
73
+ );
74
+ case MessageTypes.PING:
75
+ return (
76
+ typeof m.payload === "object" &&
77
+ m.payload !== null &&
78
+ typeof (m.payload as Record<string, unknown>).id === "string" &&
79
+ typeof (m.payload as Record<string, unknown>).timestamp === "number"
80
+ );
81
+ case MessageTypes.ASSETS_LOADED:
82
+ return m.payload === true;
83
+ default:
84
+ return false;
85
+ }
86
+ }
87
+
41
88
  export function GameHostProvider<S extends IGameState, A extends IAction>({
42
89
  children,
43
90
  config,
@@ -53,9 +100,16 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
53
100
  stateRef.current = state;
54
101
  }, [state]);
55
102
 
56
- // 1. Start Static File Server (Port 8080)
103
+ // Keep refs for callback props to avoid stale closures
104
+ const configRef = useRef(config);
105
+ useEffect(() => {
106
+ configRef.current = config;
107
+ });
108
+
109
+ // 1. Start Static File Server
110
+ const httpPort = config.port || DEFAULT_HTTP_PORT;
57
111
  const { url: serverUrl, error: serverError } = useStaticServer({
58
- port: config.port || 8080,
112
+ port: httpPort,
59
113
  devMode: config.devMode,
60
114
  devServerUrl: config.devServerUrl,
61
115
  staticDir: config.staticDir,
@@ -67,30 +121,48 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
67
121
  // Track active sessions: secret -> playerId
68
122
  const sessions = useRef<Map<string, string>>(new Map());
69
123
 
124
+ // Track socket IDs that have received their WELCOME message
125
+ const welcomedClients = useRef<Set<string>>(new Set());
126
+
70
127
  useEffect(() => {
71
- const httpPort = config.port || 8080;
72
- const port = config.wsPort || httpPort + 2;
128
+ const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
73
129
  const server = new GameWebSocketServer({ port, debug: config.debug });
74
130
 
75
131
  server.start();
76
132
  wsServer.current = server;
77
133
 
78
134
  server.on("listening", (p) => {
79
- if (config.debug)
135
+ if (configRef.current.debug)
80
136
  console.log(`[GameHost] WebSocket listening on port ${p}`);
81
137
  });
82
138
 
83
139
  server.on("connection", (socketId) => {
84
- if (config.debug) console.log(`[GameHost] Client connected: ${socketId}`);
140
+ if (configRef.current.debug)
141
+ console.log(`[GameHost] Client connected: ${socketId}`);
85
142
  });
86
143
 
87
- server.on("message", (socketId, message: ClientMessage) => {
88
- if (config.debug)
144
+ server.on("message", (socketId, rawMessage) => {
145
+ // Validate message structure before processing
146
+ if (!isValidClientMessage(rawMessage)) {
147
+ if (configRef.current.debug)
148
+ console.warn(
149
+ `[GameHost] Invalid message from ${socketId}:`,
150
+ rawMessage,
151
+ );
152
+ server.send(socketId, {
153
+ type: MessageTypes.ERROR,
154
+ payload: { code: "INVALID_MESSAGE", message: "Malformed message" },
155
+ });
156
+ return;
157
+ }
158
+
159
+ const message = rawMessage;
160
+
161
+ if (configRef.current.debug)
89
162
  console.log(`[GameHost] Msg from ${socketId}:`, message);
90
163
 
91
164
  switch (message.type) {
92
165
  case MessageTypes.JOIN: {
93
- // Check for existing session
94
166
  const { secret, ...payload } = message.payload;
95
167
 
96
168
  if (secret) {
@@ -98,25 +170,58 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
98
170
  sessions.current.set(secret, socketId);
99
171
  }
100
172
 
173
+ // Dispatch the internal PLAYER_JOINED action
101
174
  dispatch({
102
- type: "PLAYER_JOINED",
103
- payload: { id: socketId, secret, ...payload },
104
- } as unknown as A);
175
+ type: InternalActionTypes.PLAYER_JOINED,
176
+ payload: { id: socketId, ...payload },
177
+ } as InternalAction<S>);
105
178
 
106
- server.send(socketId, {
107
- type: MessageTypes.WELCOME,
108
- payload: {
109
- playerId: socketId,
110
- state: stateRef.current,
111
- serverTime: Date.now(),
112
- },
179
+ // Use queueMicrotask to send WELCOME after the reducer has processed
180
+ // the PLAYER_JOINED action, so the client receives state that includes
181
+ // themselves in the players list.
182
+ queueMicrotask(() => {
183
+ welcomedClients.current.add(socketId);
184
+ server.send(socketId, {
185
+ type: MessageTypes.WELCOME,
186
+ payload: {
187
+ playerId: socketId,
188
+ state: stateRef.current,
189
+ serverTime: Date.now(),
190
+ },
191
+ });
113
192
  });
193
+
194
+ configRef.current.onPlayerJoined?.(socketId, payload.name);
114
195
  break;
115
196
  }
116
197
 
117
- case MessageTypes.ACTION:
118
- dispatch(message.payload as A);
198
+ case MessageTypes.ACTION: {
199
+ // Only accept actions with a user-defined type string,
200
+ // reject internal action types to prevent injection.
201
+ const actionPayload = message.payload as A;
202
+ if (
203
+ actionPayload.type === InternalActionTypes.HYDRATE ||
204
+ actionPayload.type === InternalActionTypes.PLAYER_JOINED ||
205
+ actionPayload.type === InternalActionTypes.PLAYER_LEFT
206
+ ) {
207
+ if (configRef.current.debug)
208
+ console.warn(
209
+ `[GameHost] Rejected internal action from ${socketId}:`,
210
+ actionPayload.type,
211
+ );
212
+ server.send(socketId, {
213
+ type: MessageTypes.ERROR,
214
+ payload: {
215
+ code: "FORBIDDEN_ACTION",
216
+ message:
217
+ "Internal action types cannot be dispatched by clients",
218
+ },
219
+ });
220
+ return;
221
+ }
222
+ dispatch(actionPayload);
119
223
  break;
224
+ }
120
225
 
121
226
  case MessageTypes.PING:
122
227
  server.send(socketId, {
@@ -132,28 +237,38 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
132
237
  });
133
238
 
134
239
  server.on("disconnect", (socketId) => {
135
- if (config.debug)
240
+ if (configRef.current.debug)
136
241
  console.log(`[GameHost] Client disconnected: ${socketId}`);
137
242
 
243
+ welcomedClients.current.delete(socketId);
244
+
138
245
  // We do NOT remove the session from the map here,
139
246
  // allowing them to reconnect later with the same secret.
140
247
 
141
248
  dispatch({
142
- type: "PLAYER_LEFT",
249
+ type: InternalActionTypes.PLAYER_LEFT,
143
250
  payload: { playerId: socketId },
144
- } as unknown as A);
251
+ } as InternalAction<S>);
252
+
253
+ configRef.current.onPlayerLeft?.(socketId);
254
+ });
255
+
256
+ server.on("error", (error) => {
257
+ if (configRef.current.debug)
258
+ console.error(`[GameHost] Server error:`, error);
259
+ configRef.current.onError?.(error);
145
260
  });
146
261
 
147
262
  return () => {
148
263
  server.stop();
149
264
  };
265
+ // eslint-disable-next-line react-hooks/exhaustive-deps
150
266
  }, []); // Run once on mount
151
267
 
152
268
  // 3. Broadcast State Updates
153
- // Whenever React state changes, send it to all clients
269
+ // Whenever React state changes, send it to all clients that have been welcomed
154
270
  useEffect(() => {
155
271
  if (wsServer.current) {
156
- // Optimization: In the future, send deltas or only send if changed significantly
157
272
  wsServer.current.broadcast({
158
273
  type: MessageTypes.STATE_UPDATE,
159
274
  payload: {
@@ -164,15 +279,15 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
164
279
  }
165
280
  }, [state]);
166
281
 
167
- // Keep stateRef in sync inside this effect too just in case (redundant but safe)
168
- useEffect(() => {
169
- stateRef.current = state;
170
- }, [state]);
282
+ // Memoize context value to prevent unnecessary re-renders of consumers
283
+ // that only use stable references like dispatch
284
+ const contextValue = useMemo(
285
+ () => ({ state, dispatch, serverUrl, serverError }),
286
+ [state, serverUrl, serverError],
287
+ );
171
288
 
172
289
  return (
173
- <GameHostContext.Provider
174
- value={{ state, dispatch, serverUrl, serverError }}
175
- >
290
+ <GameHostContext.Provider value={contextValue}>
176
291
  {children}
177
292
  </GameHostContext.Provider>
178
293
  );
package/src/server.ts CHANGED
@@ -2,8 +2,9 @@ import { useEffect, useState } from "react";
2
2
  import { StaticServer } from "react-native-nitro-http-server";
3
3
  import RNFS from "react-native-fs";
4
4
  import { getBestIpAddress } from "./network";
5
+ import { DEFAULT_HTTP_PORT, toErrorMessage } from "@couch-kit/core";
5
6
 
6
- interface CouchKitHostConfig {
7
+ export interface CouchKitHostConfig {
7
8
  port?: number;
8
9
  devMode?: boolean;
9
10
  devServerUrl?: string; // e.g. "http://localhost:5173"
@@ -13,9 +14,11 @@ interface CouchKitHostConfig {
13
14
  export const useStaticServer = (config: CouchKitHostConfig) => {
14
15
  const [url, setUrl] = useState<string | null>(null);
15
16
  const [error, setError] = useState<Error | null>(null);
17
+ const [loading, setLoading] = useState(true);
16
18
 
17
19
  useEffect(() => {
18
20
  let server: StaticServer | null = null;
21
+ setLoading(true);
19
22
 
20
23
  const startServer = async () => {
21
24
  // In Dev Mode, we don't start the static server.
@@ -29,6 +32,7 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
29
32
  } else {
30
33
  setError(new Error("Could not detect TV IP address"));
31
34
  }
35
+ setLoading(false);
32
36
  return;
33
37
  }
34
38
 
@@ -37,7 +41,7 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
37
41
  // Use staticDir if provided (required on Android where MainBundlePath is undefined),
38
42
  // otherwise fall back to iOS MainBundlePath
39
43
  const path = config.staticDir || `${RNFS.MainBundlePath}/www`;
40
- const port = config.port || 8080;
44
+ const port = config.port || DEFAULT_HTTP_PORT;
41
45
 
42
46
  server = new StaticServer();
43
47
 
@@ -49,11 +53,13 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
49
53
  if (ip) {
50
54
  setUrl(`http://${ip}:${port}`);
51
55
  } else {
52
- // Fallback if we can't detect IP (though HttpServer doesn't return the URL directly like the old lib)
56
+ // Fallback if we can't detect IP
53
57
  setUrl(`http://localhost:${port}`);
54
58
  }
55
59
  } catch (e) {
56
- setError(e as Error);
60
+ setError(new Error(toErrorMessage(e)));
61
+ } finally {
62
+ setLoading(false);
57
63
  }
58
64
  };
59
65
 
@@ -66,5 +72,5 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
66
72
  };
67
73
  }, [config.port, config.devMode, config.devServerUrl, config.staticDir]);
68
74
 
69
- return { url, error };
75
+ return { url, error, loading };
70
76
  };
package/src/websocket.ts CHANGED
@@ -3,19 +3,40 @@
3
3
  * Built on top of react-native-tcp-socket
4
4
  *
5
5
  * Supports: text frames, close frames, ping/pong, multi-frame TCP packets,
6
- * and robust buffer management per RFC 6455.
6
+ * server-side keepalive, frame size limits, and robust buffer management per RFC 6455.
7
7
  */
8
8
 
9
9
  import TcpSocket from "react-native-tcp-socket";
10
10
  import { EventEmitter } from "./event-emitter";
11
11
  import { Buffer } from "buffer";
12
12
  import { sha1 } from "js-sha1";
13
-
14
- interface WebSocketConfig {
13
+ import {
14
+ generateId,
15
+ MAX_FRAME_SIZE,
16
+ KEEPALIVE_INTERVAL,
17
+ KEEPALIVE_TIMEOUT,
18
+ } from "@couch-kit/core";
19
+
20
+ export interface WebSocketConfig {
15
21
  port: number;
16
22
  debug?: boolean;
23
+ /** Maximum allowed frame payload size in bytes (default: 1 MB). */
24
+ maxFrameSize?: number;
25
+ /** Interval (ms) between server-side keepalive pings (default: 30s). 0 disables. */
26
+ keepaliveInterval?: number;
27
+ /** Timeout (ms) to wait for a pong after a keepalive ping (default: 10s). */
28
+ keepaliveTimeout?: number;
17
29
  }
18
30
 
31
+ /** Event map for type-safe event emission. */
32
+ export type WebSocketServerEvents = {
33
+ connection: [socketId: string];
34
+ message: [socketId: string, message: unknown];
35
+ disconnect: [socketId: string];
36
+ listening: [port: number];
37
+ error: [error: Error];
38
+ };
39
+
19
40
  // WebSocket opcodes (RFC 6455 Section 5.2)
20
41
  const OPCODE = {
21
42
  CONTINUATION: 0x0,
@@ -35,19 +56,34 @@ interface DecodedFrame {
35
56
  bytesConsumed: number;
36
57
  }
37
58
 
38
- export class GameWebSocketServer extends EventEmitter {
59
+ // Internal type for a TCP socket with our added management properties.
60
+ // We use `any` for the raw socket since react-native-tcp-socket doesn't export a clean type.
61
+ interface ManagedSocket {
39
62
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
- private server: any;
41
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
42
- private clients: Map<string, any>;
63
+ socket: any;
64
+ id: string;
65
+ isHandshakeComplete: boolean;
66
+ buffer: Buffer;
67
+ lastPong: number;
68
+ }
69
+
70
+ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
71
+ private server: ReturnType<typeof TcpSocket.createServer> | null = null;
72
+ private clients: Map<string, ManagedSocket> = new Map();
43
73
  private port: number;
44
74
  private debug: boolean;
75
+ private maxFrameSize: number;
76
+ private keepaliveInterval: number;
77
+ private keepaliveTimeout: number;
78
+ private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
45
79
 
46
80
  constructor(config: WebSocketConfig) {
47
81
  super();
48
82
  this.port = config.port;
49
83
  this.debug = !!config.debug;
50
- this.clients = new Map();
84
+ this.maxFrameSize = config.maxFrameSize ?? MAX_FRAME_SIZE;
85
+ this.keepaliveInterval = config.keepaliveInterval ?? KEEPALIVE_INTERVAL;
86
+ this.keepaliveTimeout = config.keepaliveTimeout ?? KEEPALIVE_TIMEOUT;
51
87
  }
52
88
 
53
89
  private log(...args: unknown[]) {
@@ -56,46 +92,46 @@ export class GameWebSocketServer extends EventEmitter {
56
92
  }
57
93
  }
58
94
 
59
- private generateSocketId(): string {
60
- // Generate a 21-character base36 ID for negligible collision probability
61
- const a = Math.random().toString(36).substring(2, 15); // 13 chars
62
- const b = Math.random().toString(36).substring(2, 10); // 8 chars
63
- return a + b;
64
- }
65
-
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
95
  public start() {
68
96
  this.log(`[WebSocket] Starting server on port ${this.port}...`);
97
+
69
98
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
- this.server = TcpSocket.createServer((socket: any) => {
99
+ this.server = TcpSocket.createServer((rawSocket: any) => {
71
100
  this.log(
72
- `[WebSocket] New connection from ${socket.address?.()?.address}`,
101
+ `[WebSocket] New connection from ${rawSocket.address?.()?.address}`,
73
102
  );
74
- let buffer: Buffer = Buffer.alloc(0);
75
103
 
76
- socket.on("data", (data: Buffer | string) => {
104
+ const managed: ManagedSocket = {
105
+ socket: rawSocket,
106
+ id: "",
107
+ isHandshakeComplete: false,
108
+ buffer: Buffer.alloc(0),
109
+ lastPong: Date.now(),
110
+ };
111
+
112
+ rawSocket.on("data", (data: Buffer | string) => {
77
113
  this.log(
78
114
  `[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
79
115
  );
80
116
  // Concatenate new data
81
- buffer = Buffer.concat([
82
- buffer,
117
+ managed.buffer = Buffer.concat([
118
+ managed.buffer,
83
119
  typeof data === "string" ? Buffer.from(data) : data,
84
120
  ]);
85
121
 
86
122
  // Handshake not yet performed?
87
- if (!socket.isHandshakeComplete) {
88
- const header = buffer.toString("utf8");
123
+ if (!managed.isHandshakeComplete) {
124
+ const header = managed.buffer.toString("utf8");
89
125
  const endOfHeader = header.indexOf("\r\n\r\n");
90
126
  if (endOfHeader !== -1) {
91
- this.handleHandshake(socket, header);
127
+ this.handleHandshake(managed, header);
92
128
  // Retain any bytes after the handshake (could be the first WS frame)
93
129
  const headerByteLength = Buffer.byteLength(
94
130
  header.substring(0, endOfHeader + 4),
95
131
  "utf8",
96
132
  );
97
- buffer = buffer.slice(headerByteLength);
98
- socket.isHandshakeComplete = true;
133
+ managed.buffer = managed.buffer.slice(headerByteLength);
134
+ managed.isHandshakeComplete = true;
99
135
  // Fall through to process any remaining frames below
100
136
  } else {
101
137
  return;
@@ -103,37 +139,92 @@ export class GameWebSocketServer extends EventEmitter {
103
139
  }
104
140
 
105
141
  // Process all complete frames in the buffer
106
- this.processFrames(socket, buffer, (remaining) => {
107
- buffer = remaining;
142
+ this.processFrames(managed, (remaining) => {
143
+ managed.buffer = remaining;
108
144
  });
109
145
  });
110
146
 
111
- socket.on("error", (error: Error) => {
147
+ rawSocket.on("error", (error: Error) => {
112
148
  this.emit("error", error);
113
149
  });
114
150
 
115
- socket.on("close", () => {
116
- if (socket.id) {
117
- this.clients.delete(socket.id);
118
- this.emit("disconnect", socket.id);
151
+ rawSocket.on("close", () => {
152
+ if (managed.id) {
153
+ this.clients.delete(managed.id);
154
+ this.emit("disconnect", managed.id);
119
155
  }
120
156
  });
121
157
  });
122
158
 
159
+ // Handle server-level errors (e.g., port already in use)
160
+ this.server.on("error", (error: Error) => {
161
+ this.log("[WebSocket] Server error:", error);
162
+ this.emit("error", error);
163
+ });
164
+
123
165
  this.server.listen({ port: this.port, host: "0.0.0.0" }, () => {
124
166
  this.log(`[WebSocket] Server listening on 0.0.0.0:${this.port}`);
125
167
  this.emit("listening", this.port);
126
168
  });
169
+
170
+ // Start keepalive pings if enabled
171
+ if (this.keepaliveInterval > 0) {
172
+ this.startKeepalive();
173
+ }
174
+ }
175
+
176
+ private startKeepalive() {
177
+ this.keepaliveTimer = setInterval(() => {
178
+ const now = Date.now();
179
+ const pingFrame = this.encodeControlFrame(OPCODE.PING, Buffer.alloc(0));
180
+
181
+ for (const [id, managed] of this.clients) {
182
+ // Check if previous keepalive timed out
183
+ if (
184
+ now - managed.lastPong >
185
+ this.keepaliveInterval + this.keepaliveTimeout
186
+ ) {
187
+ this.log(`[WebSocket] Keepalive timeout for ${id}, disconnecting`);
188
+ try {
189
+ managed.socket.destroy();
190
+ } catch {
191
+ // Already destroyed
192
+ }
193
+ this.clients.delete(id);
194
+ this.emit("disconnect", id);
195
+ continue;
196
+ }
197
+
198
+ try {
199
+ managed.socket.write(pingFrame);
200
+ } catch {
201
+ // Socket already closing
202
+ }
203
+ }
204
+ }, this.keepaliveInterval);
127
205
  }
128
206
 
129
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
207
  private processFrames(
131
- socket: any,
132
- buffer: Buffer,
208
+ managed: ManagedSocket,
133
209
  setBuffer: (b: Buffer) => void,
134
210
  ) {
211
+ let { buffer } = managed;
212
+
135
213
  while (buffer.length > 0) {
136
- const frame = this.decodeFrame(buffer);
214
+ let frame: DecodedFrame | null;
215
+ try {
216
+ frame = this.decodeFrame(buffer);
217
+ } catch (error) {
218
+ // Frame too large or malformed -- disconnect the client
219
+ this.log(`[WebSocket] Frame error from ${managed.id}:`, error);
220
+ try {
221
+ managed.socket.destroy();
222
+ } catch {
223
+ // Already destroyed
224
+ }
225
+ return;
226
+ }
227
+
137
228
  if (!frame) {
138
229
  // Incomplete frame -- keep buffer, wait for more data
139
230
  break;
@@ -147,37 +238,37 @@ export class GameWebSocketServer extends EventEmitter {
147
238
  case OPCODE.TEXT: {
148
239
  try {
149
240
  const message = JSON.parse(frame.payload.toString("utf8"));
150
- this.emit("message", socket.id, message);
151
- } catch (e) {
241
+ this.emit("message", managed.id, message);
242
+ } catch {
152
243
  // Corrupt JSON in a complete frame -- discard this frame, continue processing
153
244
  this.log(
154
- `[WebSocket] Invalid JSON from ${socket.id}, discarding frame`,
245
+ `[WebSocket] Invalid JSON from ${managed.id}, discarding frame`,
155
246
  );
156
247
  }
157
248
  break;
158
249
  }
159
250
 
160
251
  case OPCODE.CLOSE: {
161
- this.log(`[WebSocket] Close frame from ${socket.id}`);
252
+ this.log(`[WebSocket] Close frame from ${managed.id}`);
162
253
  // Send close frame back (RFC 6455 Section 5.5.1)
163
254
  const closeFrame = Buffer.alloc(2);
164
255
  closeFrame[0] = 0x88; // FIN + Close opcode
165
256
  closeFrame[1] = 0x00; // No payload
166
257
  try {
167
- socket.write(closeFrame);
258
+ managed.socket.write(closeFrame);
168
259
  } catch {
169
260
  // Socket may already be closing
170
261
  }
171
- socket.destroy();
262
+ managed.socket.destroy();
172
263
  break;
173
264
  }
174
265
 
175
266
  case OPCODE.PING: {
176
- this.log(`[WebSocket] Ping from ${socket.id}`);
267
+ this.log(`[WebSocket] Ping from ${managed.id}`);
177
268
  // Respond with pong containing the same payload (RFC 6455 Section 5.5.3)
178
269
  const pongFrame = this.encodeControlFrame(OPCODE.PONG, frame.payload);
179
270
  try {
180
- socket.write(pongFrame);
271
+ managed.socket.write(pongFrame);
181
272
  } catch {
182
273
  // Socket may be closing
183
274
  }
@@ -185,68 +276,132 @@ export class GameWebSocketServer extends EventEmitter {
185
276
  }
186
277
 
187
278
  case OPCODE.PONG: {
188
- // Unsolicited pong -- safe to ignore (RFC 6455 Section 5.5.3)
189
- this.log(`[WebSocket] Pong from ${socket.id}`);
279
+ // Update last-seen pong time for keepalive tracking
280
+ managed.lastPong = Date.now();
281
+ this.log(`[WebSocket] Pong from ${managed.id}`);
190
282
  break;
191
283
  }
192
284
 
193
285
  case OPCODE.BINARY: {
194
286
  // Binary frames not supported -- log and discard
195
287
  this.log(
196
- `[WebSocket] Binary frame from ${socket.id}, not supported -- discarding`,
288
+ `[WebSocket] Binary frame from ${managed.id}, not supported -- discarding`,
197
289
  );
198
290
  break;
199
291
  }
200
292
 
201
293
  default: {
202
294
  this.log(
203
- `[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${socket.id}, discarding`,
295
+ `[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${managed.id}, discarding`,
204
296
  );
205
297
  break;
206
298
  }
207
299
  }
208
300
 
209
301
  // If socket was destroyed (e.g., close frame), stop processing
210
- if (socket.destroyed) break;
302
+ if (managed.socket.destroyed) break;
211
303
  }
212
304
 
213
305
  setBuffer(buffer);
214
306
  }
215
307
 
308
+ /**
309
+ * Gracefully stop the server.
310
+ * Sends close frames to all clients before destroying connections.
311
+ */
216
312
  public stop() {
313
+ // Stop keepalive timer
314
+ if (this.keepaliveTimer) {
315
+ clearInterval(this.keepaliveTimer);
316
+ this.keepaliveTimer = null;
317
+ }
318
+
217
319
  if (this.server) {
218
- this.server.close();
219
- this.clients.forEach((socket) => socket.destroy());
320
+ // Send close frames to all clients before destroying
321
+ const closeFrame = Buffer.alloc(2);
322
+ closeFrame[0] = 0x88; // FIN + Close opcode
323
+ closeFrame[1] = 0x00; // No payload
324
+
325
+ this.clients.forEach((managed) => {
326
+ try {
327
+ managed.socket.write(closeFrame);
328
+ } catch {
329
+ // Socket may already be closing
330
+ }
331
+ try {
332
+ managed.socket.destroy();
333
+ } catch {
334
+ // Already destroyed
335
+ }
336
+ });
337
+
220
338
  this.clients.clear();
339
+ this.server.close();
221
340
  }
222
341
  }
223
342
 
343
+ /**
344
+ * Send data to a specific client by socket ID.
345
+ * Silently ignores unknown socket IDs and write errors.
346
+ */
224
347
  public send(socketId: string, data: unknown) {
225
- const socket = this.clients.get(socketId);
226
- if (socket) {
227
- const frame = this.encodeFrame(JSON.stringify(data));
228
- socket.write(frame);
348
+ const managed = this.clients.get(socketId);
349
+ if (managed) {
350
+ try {
351
+ const frame = this.encodeFrame(JSON.stringify(data));
352
+ managed.socket.write(frame);
353
+ } catch (error) {
354
+ this.log(`[WebSocket] Failed to send to ${socketId}:`, error);
355
+ this.emit(
356
+ "error",
357
+ error instanceof Error ? error : new Error(String(error)),
358
+ );
359
+ }
229
360
  }
230
361
  }
231
362
 
363
+ /**
364
+ * Broadcast data to all connected clients.
365
+ * Wraps each write in try/catch so a single dead socket doesn't skip remaining clients.
366
+ */
232
367
  public broadcast(data: unknown, excludeId?: string) {
233
368
  const frame = this.encodeFrame(JSON.stringify(data));
234
- this.clients.forEach((socket, id) => {
369
+ this.clients.forEach((managed, id) => {
235
370
  if (id !== excludeId) {
236
- socket.write(frame);
371
+ try {
372
+ managed.socket.write(frame);
373
+ } catch (error) {
374
+ this.log(`[WebSocket] Failed to broadcast to ${id}:`, error);
375
+ // Don't abort -- continue sending to remaining clients
376
+ }
237
377
  }
238
378
  });
239
379
  }
240
380
 
381
+ /** Returns the number of currently connected clients. */
382
+ public get clientCount(): number {
383
+ return this.clients.size;
384
+ }
385
+
241
386
  // --- Private Helpers ---
242
387
 
243
388
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
244
- private handleHandshake(socket: any, header: string) {
389
+ private handleHandshake(managed: ManagedSocket, header: string) {
245
390
  this.log("[WebSocket] Handshake request header:", JSON.stringify(header));
246
391
  const keyMatch = header.match(/Sec-WebSocket-Key: (.+)/);
247
392
  if (!keyMatch) {
248
393
  console.error("[WebSocket] Handshake failed: No Sec-WebSocket-Key found");
249
- socket.destroy();
394
+ managed.socket.destroy();
395
+ return;
396
+ }
397
+
398
+ // Validate Sec-WebSocket-Version (RFC 6455 Section 4.2.1)
399
+ const versionMatch = header.match(/Sec-WebSocket-Version: (\d+)/);
400
+ if (versionMatch && versionMatch[1] !== "13") {
401
+ console.error(
402
+ `[WebSocket] Unsupported WebSocket version: ${versionMatch[1]}`,
403
+ );
404
+ managed.socket.destroy();
250
405
  return;
251
406
  }
252
407
 
@@ -269,15 +424,15 @@ export class GameWebSocketServer extends EventEmitter {
269
424
  "[WebSocket] Sending Handshake Response:",
270
425
  JSON.stringify(response),
271
426
  );
272
- socket.write(response);
427
+ managed.socket.write(response);
273
428
 
274
- // Assign ID and store
275
- socket.id = this.generateSocketId();
276
- this.clients.set(socket.id, socket);
277
- this.emit("connection", socket.id);
429
+ // Assign cryptographically random ID and store
430
+ managed.id = generateId();
431
+ this.clients.set(managed.id, managed);
432
+ this.emit("connection", managed.id);
278
433
  } catch (error) {
279
434
  console.error("[WebSocket] Handshake error:", error);
280
- socket.destroy();
435
+ managed.socket.destroy();
281
436
  }
282
437
  }
283
438
 
@@ -310,13 +465,19 @@ export class GameWebSocketServer extends EventEmitter {
310
465
  // Read 64-bit length. For safety, only use the lower 32 bits.
311
466
  const highBits = buffer.readUInt32BE(2);
312
467
  if (highBits > 0) {
313
- // Payload > 4GB -- reject
314
- throw new Error("Frame payload too large");
468
+ throw new Error("Frame payload too large (exceeds 4 GB)");
315
469
  }
316
470
  payloadLength = buffer.readUInt32BE(6);
317
471
  headerLength = 10;
318
472
  }
319
473
 
474
+ // Enforce max frame size to prevent memory exhaustion attacks
475
+ if (payloadLength > this.maxFrameSize) {
476
+ throw new Error(
477
+ `Frame payload (${payloadLength} bytes) exceeds maximum allowed size (${this.maxFrameSize} bytes)`,
478
+ );
479
+ }
480
+
320
481
  const maskLength = isMasked ? 4 : 0;
321
482
  const totalFrameLength = headerLength + maskLength + payloadLength;
322
483
 
package/src/events.d.ts DELETED
@@ -1,20 +0,0 @@
1
- import { EventEmitter } from "./event-emitter";
2
-
3
- // Since the EventEmitter type definition might not perfectly match the Node.js one in RN environment,
4
- // we'll declare the interface we expect.
5
- export interface GameWebSocketServer extends EventEmitter {
6
- on(event: "connection", listener: (socketId: string) => void): this;
7
- on(
8
- event: "message",
9
- listener: (socketId: string, message: unknown) => void,
10
- ): this;
11
- on(event: "disconnect", listener: (socketId: string) => void): this;
12
- on(event: "listening", listener: (port: number) => void): this;
13
- on(event: "error", listener: (error: Error) => void): this;
14
-
15
- emit(event: "connection", socketId: string): boolean;
16
- emit(event: "message", socketId: string, message: unknown): boolean;
17
- emit(event: "disconnect", socketId: string): boolean;
18
- emit(event: "listening", port: number): boolean;
19
- emit(event: "error", error: Error): boolean;
20
- }