@couch-kit/host 0.3.0 → 0.5.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,9 +1,35 @@
1
1
  {
2
2
  "name": "@couch-kit/host",
3
- "version": "0.3.0",
3
+ "version": "0.5.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
+ "exports": {
26
+ ".": {
27
+ "react-native": "./src/index.tsx",
28
+ "types": "./src/index.tsx",
29
+ "default": "./src/index.tsx"
30
+ }
31
+ },
32
+ "sideEffects": false,
7
33
  "files": [
8
34
  "src",
9
35
  "lib",
@@ -25,12 +51,12 @@
25
51
  "clean": "del-cli lib"
26
52
  },
27
53
  "dependencies": {
28
- "@couch-kit/core": "0.2.0",
54
+ "@couch-kit/core": "0.3.1",
55
+ "buffer": "^6.0.3",
29
56
  "js-sha1": "^0.7.0",
30
57
  "react-native-fs": "^2.20.0",
31
58
  "react-native-network-info": "^5.2.1",
32
59
  "react-native-nitro-http-server": "^1.5.4",
33
- "react-native-nitro-modules": "^0.33.2",
34
60
  "react-native-tcp-socket": "^6.0.6"
35
61
  },
36
62
  "devDependencies": {
@@ -43,6 +69,7 @@
43
69
  },
44
70
  "peerDependencies": {
45
71
  "react": "*",
46
- "react-native": "*"
72
+ "react-native": "*",
73
+ "react-native-nitro-modules": ">=0.33.0"
47
74
  }
48
75
  }
@@ -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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
7
- type Listener = (...args: any[]) => void;
19
+ /* eslint-disable @typescript-eslint/no-explicit-any */
20
+ export class EventEmitter<
21
+ EventMap extends Record<string, any[]> = Record<string, any[]>,
22
+ > {
23
+ private listeners: Map<string, Array<(...args: any[]) => void>> = new Map();
24
+ /* eslint-enable @typescript-eslint/no-explicit-any */
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,32 +2,44 @@ import React, {
2
2
  createContext,
3
3
  useContext,
4
4
  useEffect,
5
+ useMemo,
5
6
  useReducer,
6
7
  useRef,
8
+ useCallback,
7
9
  } from "react";
8
10
  import { GameWebSocketServer } from "./websocket";
9
11
  import { useStaticServer } from "./server";
10
12
  import {
11
13
  MessageTypes,
14
+ InternalActionTypes,
15
+ DEFAULT_HTTP_PORT,
16
+ DEFAULT_WS_PORT_OFFSET,
12
17
  type IGameState,
13
18
  type IAction,
19
+ type InternalAction,
14
20
  type ClientMessage,
15
21
  } from "@couch-kit/core";
16
22
 
17
- interface GameHostConfig<S extends IGameState, A extends IAction> {
23
+ export interface GameHostConfig<S extends IGameState, A extends IAction> {
18
24
  initialState: S;
19
- reducer: (state: S, action: A) => S;
25
+ reducer: (state: S, action: A | InternalAction<S>) => S;
20
26
  port?: number; // Static server port (default 8080)
21
27
  wsPort?: number; // WebSocket port (default: HTTP port + 2, i.e. 8082)
22
28
  devMode?: boolean;
23
29
  devServerUrl?: string;
24
30
  staticDir?: string; // Override the default www directory path (required on Android)
25
31
  debug?: boolean;
32
+ /** Called when a player successfully joins. */
33
+ onPlayerJoined?: (playerId: string, name: string) => void;
34
+ /** Called when a player disconnects. */
35
+ onPlayerLeft?: (playerId: string) => void;
36
+ /** Called when a server error occurs. */
37
+ onError?: (error: Error) => void;
26
38
  }
27
39
 
28
40
  interface GameHostContextValue<S extends IGameState, A extends IAction> {
29
41
  state: S;
30
- dispatch: (action: A) => void;
42
+ dispatch: (action: A | InternalAction<S>) => void;
31
43
  serverUrl: string | null;
32
44
  serverError: Error | null;
33
45
  }
@@ -38,6 +50,42 @@ const GameHostContext = createContext<GameHostContextValue<any, any> | null>(
38
50
  null,
39
51
  );
40
52
 
53
+ /**
54
+ * Validates that an incoming message has the expected shape.
55
+ * Returns true if the message is a valid ClientMessage, false otherwise.
56
+ */
57
+ function isValidClientMessage(msg: unknown): msg is ClientMessage {
58
+ if (typeof msg !== "object" || msg === null) return false;
59
+ const m = msg as Record<string, unknown>;
60
+ if (typeof m.type !== "string") return false;
61
+
62
+ switch (m.type) {
63
+ case MessageTypes.JOIN:
64
+ return (
65
+ typeof m.payload === "object" &&
66
+ m.payload !== null &&
67
+ typeof (m.payload as Record<string, unknown>).name === "string"
68
+ );
69
+ case MessageTypes.ACTION:
70
+ return (
71
+ typeof m.payload === "object" &&
72
+ m.payload !== null &&
73
+ typeof (m.payload as Record<string, unknown>).type === "string"
74
+ );
75
+ case MessageTypes.PING:
76
+ return (
77
+ typeof m.payload === "object" &&
78
+ m.payload !== null &&
79
+ typeof (m.payload as Record<string, unknown>).id === "string" &&
80
+ typeof (m.payload as Record<string, unknown>).timestamp === "number"
81
+ );
82
+ case MessageTypes.ASSETS_LOADED:
83
+ return m.payload === true;
84
+ default:
85
+ return false;
86
+ }
87
+ }
88
+
41
89
  export function GameHostProvider<S extends IGameState, A extends IAction>({
42
90
  children,
43
91
  config,
@@ -53,9 +101,16 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
53
101
  stateRef.current = state;
54
102
  }, [state]);
55
103
 
56
- // 1. Start Static File Server (Port 8080)
104
+ // Keep refs for callback props to avoid stale closures
105
+ const configRef = useRef(config);
106
+ useEffect(() => {
107
+ configRef.current = config;
108
+ });
109
+
110
+ // 1. Start Static File Server
111
+ const httpPort = config.port || DEFAULT_HTTP_PORT;
57
112
  const { url: serverUrl, error: serverError } = useStaticServer({
58
- port: config.port || 8080,
113
+ port: httpPort,
59
114
  devMode: config.devMode,
60
115
  devServerUrl: config.devServerUrl,
61
116
  staticDir: config.staticDir,
@@ -67,30 +122,48 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
67
122
  // Track active sessions: secret -> playerId
68
123
  const sessions = useRef<Map<string, string>>(new Map());
69
124
 
125
+ // Track socket IDs that have received their WELCOME message
126
+ const welcomedClients = useRef<Set<string>>(new Set());
127
+
70
128
  useEffect(() => {
71
- const httpPort = config.port || 8080;
72
- const port = config.wsPort || httpPort + 2;
129
+ const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
73
130
  const server = new GameWebSocketServer({ port, debug: config.debug });
74
131
 
75
132
  server.start();
76
133
  wsServer.current = server;
77
134
 
78
135
  server.on("listening", (p) => {
79
- if (config.debug)
136
+ if (configRef.current.debug)
80
137
  console.log(`[GameHost] WebSocket listening on port ${p}`);
81
138
  });
82
139
 
83
140
  server.on("connection", (socketId) => {
84
- if (config.debug) console.log(`[GameHost] Client connected: ${socketId}`);
141
+ if (configRef.current.debug)
142
+ console.log(`[GameHost] Client connected: ${socketId}`);
85
143
  });
86
144
 
87
- server.on("message", (socketId, message: ClientMessage) => {
88
- if (config.debug)
145
+ server.on("message", (socketId, rawMessage) => {
146
+ // Validate message structure before processing
147
+ if (!isValidClientMessage(rawMessage)) {
148
+ if (configRef.current.debug)
149
+ console.warn(
150
+ `[GameHost] Invalid message from ${socketId}:`,
151
+ rawMessage,
152
+ );
153
+ server.send(socketId, {
154
+ type: MessageTypes.ERROR,
155
+ payload: { code: "INVALID_MESSAGE", message: "Malformed message" },
156
+ });
157
+ return;
158
+ }
159
+
160
+ const message = rawMessage;
161
+
162
+ if (configRef.current.debug)
89
163
  console.log(`[GameHost] Msg from ${socketId}:`, message);
90
164
 
91
165
  switch (message.type) {
92
166
  case MessageTypes.JOIN: {
93
- // Check for existing session
94
167
  const { secret, ...payload } = message.payload;
95
168
 
96
169
  if (secret) {
@@ -98,25 +171,58 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
98
171
  sessions.current.set(secret, socketId);
99
172
  }
100
173
 
174
+ // Dispatch the internal PLAYER_JOINED action
101
175
  dispatch({
102
- type: "PLAYER_JOINED",
103
- payload: { id: socketId, secret, ...payload },
104
- } as unknown as A);
176
+ type: InternalActionTypes.PLAYER_JOINED,
177
+ payload: { id: socketId, ...payload },
178
+ } as InternalAction<S>);
105
179
 
106
- server.send(socketId, {
107
- type: MessageTypes.WELCOME,
108
- payload: {
109
- playerId: socketId,
110
- state: stateRef.current,
111
- serverTime: Date.now(),
112
- },
180
+ // Use queueMicrotask to send WELCOME after the reducer has processed
181
+ // the PLAYER_JOINED action, so the client receives state that includes
182
+ // themselves in the players list.
183
+ queueMicrotask(() => {
184
+ welcomedClients.current.add(socketId);
185
+ server.send(socketId, {
186
+ type: MessageTypes.WELCOME,
187
+ payload: {
188
+ playerId: socketId,
189
+ state: stateRef.current,
190
+ serverTime: Date.now(),
191
+ },
192
+ });
113
193
  });
194
+
195
+ configRef.current.onPlayerJoined?.(socketId, payload.name);
114
196
  break;
115
197
  }
116
198
 
117
- case MessageTypes.ACTION:
118
- dispatch(message.payload as A);
199
+ case MessageTypes.ACTION: {
200
+ // Only accept actions with a user-defined type string,
201
+ // reject internal action types to prevent injection.
202
+ const actionPayload = message.payload as A;
203
+ if (
204
+ actionPayload.type === InternalActionTypes.HYDRATE ||
205
+ actionPayload.type === InternalActionTypes.PLAYER_JOINED ||
206
+ actionPayload.type === InternalActionTypes.PLAYER_LEFT
207
+ ) {
208
+ if (configRef.current.debug)
209
+ console.warn(
210
+ `[GameHost] Rejected internal action from ${socketId}:`,
211
+ actionPayload.type,
212
+ );
213
+ server.send(socketId, {
214
+ type: MessageTypes.ERROR,
215
+ payload: {
216
+ code: "FORBIDDEN_ACTION",
217
+ message:
218
+ "Internal action types cannot be dispatched by clients",
219
+ },
220
+ });
221
+ return;
222
+ }
223
+ dispatch(actionPayload);
119
224
  break;
225
+ }
120
226
 
121
227
  case MessageTypes.PING:
122
228
  server.send(socketId, {
@@ -132,16 +238,26 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
132
238
  });
133
239
 
134
240
  server.on("disconnect", (socketId) => {
135
- if (config.debug)
241
+ if (configRef.current.debug)
136
242
  console.log(`[GameHost] Client disconnected: ${socketId}`);
137
243
 
244
+ welcomedClients.current.delete(socketId);
245
+
138
246
  // We do NOT remove the session from the map here,
139
247
  // allowing them to reconnect later with the same secret.
140
248
 
141
249
  dispatch({
142
- type: "PLAYER_LEFT",
250
+ type: InternalActionTypes.PLAYER_LEFT,
143
251
  payload: { playerId: socketId },
144
- } as unknown as A);
252
+ } as InternalAction<S>);
253
+
254
+ configRef.current.onPlayerLeft?.(socketId);
255
+ });
256
+
257
+ server.on("error", (error) => {
258
+ if (configRef.current.debug)
259
+ console.error(`[GameHost] Server error:`, error);
260
+ configRef.current.onError?.(error);
145
261
  });
146
262
 
147
263
  return () => {
@@ -149,30 +265,48 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
149
265
  };
150
266
  }, []); // Run once on mount
151
267
 
152
- // 3. Broadcast State Updates
153
- // Whenever React state changes, send it to all clients
154
- useEffect(() => {
268
+ // 3. Throttled State Broadcasts (~30fps)
269
+ // Batches rapid state changes so at most one broadcast is sent per ~33ms frame,
270
+ // reducing serialization overhead and network traffic for fast-updating games.
271
+ const broadcastPending = useRef(false);
272
+ const broadcastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
273
+
274
+ const broadcastState = useCallback(() => {
155
275
  if (wsServer.current) {
156
- // Optimization: In the future, send deltas or only send if changed significantly
157
276
  wsServer.current.broadcast({
158
277
  type: MessageTypes.STATE_UPDATE,
159
278
  payload: {
160
- newState: state,
279
+ newState: stateRef.current,
161
280
  timestamp: Date.now(),
162
281
  },
163
282
  });
164
283
  }
165
- }, [state]);
284
+ broadcastPending.current = false;
285
+ }, []);
166
286
 
167
- // Keep stateRef in sync inside this effect too just in case (redundant but safe)
168
287
  useEffect(() => {
169
- stateRef.current = state;
170
- }, [state]);
288
+ if (!broadcastPending.current) {
289
+ broadcastPending.current = true;
290
+ broadcastTimer.current = setTimeout(broadcastState, 33); // ~30fps
291
+ }
292
+
293
+ return () => {
294
+ if (broadcastTimer.current) {
295
+ clearTimeout(broadcastTimer.current);
296
+ broadcastTimer.current = null;
297
+ }
298
+ };
299
+ }, [state, broadcastState]);
300
+
301
+ // Memoize context value to prevent unnecessary re-renders of consumers
302
+ // that only use stable references like dispatch
303
+ const contextValue = useMemo(
304
+ () => ({ state, dispatch, serverUrl, serverError }),
305
+ [state, serverUrl, serverError],
306
+ );
171
307
 
172
308
  return (
173
- <GameHostContext.Provider
174
- value={{ state, dispatch, serverUrl, serverError }}
175
- >
309
+ <GameHostContext.Provider value={contextValue}>
176
310
  {children}
177
311
  </GameHostContext.Provider>
178
312
  );
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,
@@ -29,25 +50,79 @@ const OPCODE = {
29
50
  // Simple WebSocket Frame Parser/Builder
30
51
  const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
31
52
 
53
+ /** Initial capacity for per-client receive buffers. */
54
+ const INITIAL_BUFFER_CAPACITY = 4096;
55
+
56
+ /**
57
+ * Append data to a managed socket's buffer, growing capacity geometrically
58
+ * to avoid re-allocation on every TCP data event.
59
+ */
60
+ function appendToBuffer(managed: ManagedSocket, data: Buffer): void {
61
+ const needed = managed.bufferLength + data.length;
62
+
63
+ if (needed > managed.buffer.length) {
64
+ // Grow by at least 2x or to fit the new data, whichever is larger
65
+ const newCapacity = Math.max(managed.buffer.length * 2, needed);
66
+ const grown = Buffer.alloc(newCapacity);
67
+ managed.buffer.copy(grown, 0, 0, managed.bufferLength);
68
+ managed.buffer = grown;
69
+ }
70
+
71
+ data.copy(managed.buffer, managed.bufferLength);
72
+ managed.bufferLength = needed;
73
+ }
74
+
75
+ /**
76
+ * Compact the buffer by discarding consumed bytes from the front.
77
+ * If all data has been consumed, reset the length to 0 without re-allocating.
78
+ */
79
+ function compactBuffer(managed: ManagedSocket, consumed: number): void {
80
+ const remaining = managed.bufferLength - consumed;
81
+ if (remaining <= 0) {
82
+ managed.bufferLength = 0;
83
+ return;
84
+ }
85
+ // Shift remaining bytes to the front of the existing buffer
86
+ managed.buffer.copy(managed.buffer, 0, consumed, managed.bufferLength);
87
+ managed.bufferLength = remaining;
88
+ }
89
+
32
90
  interface DecodedFrame {
33
91
  opcode: number;
34
92
  payload: Buffer;
35
93
  bytesConsumed: number;
36
94
  }
37
95
 
38
- export class GameWebSocketServer extends EventEmitter {
96
+ // Internal type for a TCP socket with our added management properties.
97
+ // We use `any` for the raw socket since react-native-tcp-socket doesn't export a clean type.
98
+ interface ManagedSocket {
39
99
  // 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>;
100
+ socket: any;
101
+ id: string;
102
+ isHandshakeComplete: boolean;
103
+ buffer: Buffer;
104
+ /** Number of valid bytes currently in `buffer` (may be less than buffer.length). */
105
+ bufferLength: number;
106
+ lastPong: number;
107
+ }
108
+
109
+ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
110
+ private server: ReturnType<typeof TcpSocket.createServer> | null = null;
111
+ private clients: Map<string, ManagedSocket> = new Map();
43
112
  private port: number;
44
113
  private debug: boolean;
114
+ private maxFrameSize: number;
115
+ private keepaliveInterval: number;
116
+ private keepaliveTimeout: number;
117
+ private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
45
118
 
46
119
  constructor(config: WebSocketConfig) {
47
120
  super();
48
121
  this.port = config.port;
49
122
  this.debug = !!config.debug;
50
- this.clients = new Map();
123
+ this.maxFrameSize = config.maxFrameSize ?? MAX_FRAME_SIZE;
124
+ this.keepaliveInterval = config.keepaliveInterval ?? KEEPALIVE_INTERVAL;
125
+ this.keepaliveTimeout = config.keepaliveTimeout ?? KEEPALIVE_TIMEOUT;
51
126
  }
52
127
 
53
128
  private log(...args: unknown[]) {
@@ -56,46 +131,49 @@ export class GameWebSocketServer extends EventEmitter {
56
131
  }
57
132
  }
58
133
 
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
134
  public start() {
68
135
  this.log(`[WebSocket] Starting server on port ${this.port}...`);
136
+
69
137
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
- this.server = TcpSocket.createServer((socket: any) => {
138
+ this.server = TcpSocket.createServer((rawSocket: any) => {
71
139
  this.log(
72
- `[WebSocket] New connection from ${socket.address?.()?.address}`,
140
+ `[WebSocket] New connection from ${rawSocket.address?.()?.address}`,
73
141
  );
74
- let buffer: Buffer = Buffer.alloc(0);
75
142
 
76
- socket.on("data", (data: Buffer | string) => {
143
+ const managed: ManagedSocket = {
144
+ socket: rawSocket,
145
+ id: "",
146
+ isHandshakeComplete: false,
147
+ buffer: Buffer.alloc(INITIAL_BUFFER_CAPACITY),
148
+ bufferLength: 0,
149
+ lastPong: Date.now(),
150
+ };
151
+
152
+ rawSocket.on("data", (data: Buffer | string) => {
77
153
  this.log(
78
154
  `[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
79
155
  );
80
- // Concatenate new data
81
- buffer = Buffer.concat([
82
- buffer,
83
- typeof data === "string" ? Buffer.from(data) : data,
84
- ]);
156
+ // Append new data using growing buffer strategy (avoids Buffer.concat per event)
157
+ const incoming = typeof data === "string" ? Buffer.from(data) : data;
158
+ appendToBuffer(managed, incoming);
85
159
 
86
160
  // Handshake not yet performed?
87
- if (!socket.isHandshakeComplete) {
88
- const header = buffer.toString("utf8");
161
+ if (!managed.isHandshakeComplete) {
162
+ const header = managed.buffer.toString(
163
+ "utf8",
164
+ 0,
165
+ managed.bufferLength,
166
+ );
89
167
  const endOfHeader = header.indexOf("\r\n\r\n");
90
168
  if (endOfHeader !== -1) {
91
- this.handleHandshake(socket, header);
92
- // Retain any bytes after the handshake (could be the first WS frame)
169
+ this.handleHandshake(managed, header);
170
+ // Compact buffer past the handshake header
93
171
  const headerByteLength = Buffer.byteLength(
94
172
  header.substring(0, endOfHeader + 4),
95
173
  "utf8",
96
174
  );
97
- buffer = buffer.slice(headerByteLength);
98
- socket.isHandshakeComplete = true;
175
+ compactBuffer(managed, headerByteLength);
176
+ managed.isHandshakeComplete = true;
99
177
  // Fall through to process any remaining frames below
100
178
  } else {
101
179
  return;
@@ -103,81 +181,133 @@ export class GameWebSocketServer extends EventEmitter {
103
181
  }
104
182
 
105
183
  // Process all complete frames in the buffer
106
- this.processFrames(socket, buffer, (remaining) => {
107
- buffer = remaining;
108
- });
184
+ this.processFrames(managed);
109
185
  });
110
186
 
111
- socket.on("error", (error: Error) => {
187
+ rawSocket.on("error", (error: Error) => {
112
188
  this.emit("error", error);
113
189
  });
114
190
 
115
- socket.on("close", () => {
116
- if (socket.id) {
117
- this.clients.delete(socket.id);
118
- this.emit("disconnect", socket.id);
191
+ rawSocket.on("close", () => {
192
+ if (managed.id) {
193
+ this.clients.delete(managed.id);
194
+ this.emit("disconnect", managed.id);
119
195
  }
120
196
  });
121
197
  });
122
198
 
199
+ // Handle server-level errors (e.g., port already in use)
200
+ this.server.on("error", (error: Error) => {
201
+ this.log("[WebSocket] Server error:", error);
202
+ this.emit("error", error);
203
+ });
204
+
123
205
  this.server.listen({ port: this.port, host: "0.0.0.0" }, () => {
124
206
  this.log(`[WebSocket] Server listening on 0.0.0.0:${this.port}`);
125
207
  this.emit("listening", this.port);
126
208
  });
209
+
210
+ // Start keepalive pings if enabled
211
+ if (this.keepaliveInterval > 0) {
212
+ this.startKeepalive();
213
+ }
127
214
  }
128
215
 
129
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
130
- private processFrames(
131
- socket: any,
132
- buffer: Buffer,
133
- setBuffer: (b: Buffer) => void,
134
- ) {
135
- while (buffer.length > 0) {
136
- const frame = this.decodeFrame(buffer);
216
+ private startKeepalive() {
217
+ this.keepaliveTimer = setInterval(() => {
218
+ const now = Date.now();
219
+ const pingFrame = this.encodeControlFrame(OPCODE.PING, Buffer.alloc(0));
220
+
221
+ for (const [id, managed] of this.clients) {
222
+ // Check if previous keepalive timed out
223
+ if (
224
+ now - managed.lastPong >
225
+ this.keepaliveInterval + this.keepaliveTimeout
226
+ ) {
227
+ this.log(`[WebSocket] Keepalive timeout for ${id}, disconnecting`);
228
+ try {
229
+ managed.socket.destroy();
230
+ } catch {
231
+ // Already destroyed
232
+ }
233
+ this.clients.delete(id);
234
+ this.emit("disconnect", id);
235
+ continue;
236
+ }
237
+
238
+ try {
239
+ managed.socket.write(pingFrame);
240
+ } catch {
241
+ // Socket already closing
242
+ }
243
+ }
244
+ }, this.keepaliveInterval);
245
+ }
246
+
247
+ private processFrames(managed: ManagedSocket) {
248
+ let offset = 0;
249
+
250
+ while (offset < managed.bufferLength) {
251
+ // Create a view of the unconsumed portion for decoding
252
+ const view = managed.buffer.subarray(offset, managed.bufferLength);
253
+ let frame: DecodedFrame | null;
254
+ try {
255
+ frame = this.decodeFrame(view);
256
+ } catch (error) {
257
+ // Frame too large or malformed -- disconnect the client
258
+ this.log(`[WebSocket] Frame error from ${managed.id}:`, error);
259
+ try {
260
+ managed.socket.destroy();
261
+ } catch {
262
+ // Already destroyed
263
+ }
264
+ return;
265
+ }
266
+
137
267
  if (!frame) {
138
- // Incomplete frame -- keep buffer, wait for more data
268
+ // Incomplete frame -- keep remaining bytes, wait for more data
139
269
  break;
140
270
  }
141
271
 
142
- // Advance buffer past the consumed frame
143
- buffer = buffer.slice(frame.bytesConsumed);
272
+ // Advance past the consumed frame
273
+ offset += frame.bytesConsumed;
144
274
 
145
275
  // Handle frame by opcode
146
276
  switch (frame.opcode) {
147
277
  case OPCODE.TEXT: {
148
278
  try {
149
279
  const message = JSON.parse(frame.payload.toString("utf8"));
150
- this.emit("message", socket.id, message);
151
- } catch (e) {
280
+ this.emit("message", managed.id, message);
281
+ } catch {
152
282
  // Corrupt JSON in a complete frame -- discard this frame, continue processing
153
283
  this.log(
154
- `[WebSocket] Invalid JSON from ${socket.id}, discarding frame`,
284
+ `[WebSocket] Invalid JSON from ${managed.id}, discarding frame`,
155
285
  );
156
286
  }
157
287
  break;
158
288
  }
159
289
 
160
290
  case OPCODE.CLOSE: {
161
- this.log(`[WebSocket] Close frame from ${socket.id}`);
291
+ this.log(`[WebSocket] Close frame from ${managed.id}`);
162
292
  // Send close frame back (RFC 6455 Section 5.5.1)
163
293
  const closeFrame = Buffer.alloc(2);
164
294
  closeFrame[0] = 0x88; // FIN + Close opcode
165
295
  closeFrame[1] = 0x00; // No payload
166
296
  try {
167
- socket.write(closeFrame);
297
+ managed.socket.write(closeFrame);
168
298
  } catch {
169
299
  // Socket may already be closing
170
300
  }
171
- socket.destroy();
301
+ managed.socket.destroy();
172
302
  break;
173
303
  }
174
304
 
175
305
  case OPCODE.PING: {
176
- this.log(`[WebSocket] Ping from ${socket.id}`);
306
+ this.log(`[WebSocket] Ping from ${managed.id}`);
177
307
  // Respond with pong containing the same payload (RFC 6455 Section 5.5.3)
178
308
  const pongFrame = this.encodeControlFrame(OPCODE.PONG, frame.payload);
179
309
  try {
180
- socket.write(pongFrame);
310
+ managed.socket.write(pongFrame);
181
311
  } catch {
182
312
  // Socket may be closing
183
313
  }
@@ -185,68 +315,133 @@ export class GameWebSocketServer extends EventEmitter {
185
315
  }
186
316
 
187
317
  case OPCODE.PONG: {
188
- // Unsolicited pong -- safe to ignore (RFC 6455 Section 5.5.3)
189
- this.log(`[WebSocket] Pong from ${socket.id}`);
318
+ // Update last-seen pong time for keepalive tracking
319
+ managed.lastPong = Date.now();
320
+ this.log(`[WebSocket] Pong from ${managed.id}`);
190
321
  break;
191
322
  }
192
323
 
193
324
  case OPCODE.BINARY: {
194
325
  // Binary frames not supported -- log and discard
195
326
  this.log(
196
- `[WebSocket] Binary frame from ${socket.id}, not supported -- discarding`,
327
+ `[WebSocket] Binary frame from ${managed.id}, not supported -- discarding`,
197
328
  );
198
329
  break;
199
330
  }
200
331
 
201
332
  default: {
202
333
  this.log(
203
- `[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${socket.id}, discarding`,
334
+ `[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${managed.id}, discarding`,
204
335
  );
205
336
  break;
206
337
  }
207
338
  }
208
339
 
209
340
  // If socket was destroyed (e.g., close frame), stop processing
210
- if (socket.destroyed) break;
341
+ if (managed.socket.destroyed) break;
211
342
  }
212
343
 
213
- setBuffer(buffer);
344
+ // Compact buffer: shift unconsumed bytes to the front
345
+ compactBuffer(managed, offset);
214
346
  }
215
347
 
348
+ /**
349
+ * Gracefully stop the server.
350
+ * Sends close frames to all clients before destroying connections.
351
+ */
216
352
  public stop() {
353
+ // Stop keepalive timer
354
+ if (this.keepaliveTimer) {
355
+ clearInterval(this.keepaliveTimer);
356
+ this.keepaliveTimer = null;
357
+ }
358
+
217
359
  if (this.server) {
218
- this.server.close();
219
- this.clients.forEach((socket) => socket.destroy());
360
+ // Send close frames to all clients before destroying
361
+ const closeFrame = Buffer.alloc(2);
362
+ closeFrame[0] = 0x88; // FIN + Close opcode
363
+ closeFrame[1] = 0x00; // No payload
364
+
365
+ this.clients.forEach((managed) => {
366
+ try {
367
+ managed.socket.write(closeFrame);
368
+ } catch {
369
+ // Socket may already be closing
370
+ }
371
+ try {
372
+ managed.socket.destroy();
373
+ } catch {
374
+ // Already destroyed
375
+ }
376
+ });
377
+
220
378
  this.clients.clear();
379
+ this.server.close();
221
380
  }
222
381
  }
223
382
 
383
+ /**
384
+ * Send data to a specific client by socket ID.
385
+ * Silently ignores unknown socket IDs and write errors.
386
+ */
224
387
  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);
388
+ const managed = this.clients.get(socketId);
389
+ if (managed) {
390
+ try {
391
+ const frame = this.encodeFrame(JSON.stringify(data));
392
+ managed.socket.write(frame);
393
+ } catch (error) {
394
+ this.log(`[WebSocket] Failed to send to ${socketId}:`, error);
395
+ this.emit(
396
+ "error",
397
+ error instanceof Error ? error : new Error(String(error)),
398
+ );
399
+ }
229
400
  }
230
401
  }
231
402
 
403
+ /**
404
+ * Broadcast data to all connected clients.
405
+ * Wraps each write in try/catch so a single dead socket doesn't skip remaining clients.
406
+ */
232
407
  public broadcast(data: unknown, excludeId?: string) {
233
408
  const frame = this.encodeFrame(JSON.stringify(data));
234
- this.clients.forEach((socket, id) => {
409
+ this.clients.forEach((managed, id) => {
235
410
  if (id !== excludeId) {
236
- socket.write(frame);
411
+ try {
412
+ managed.socket.write(frame);
413
+ } catch (error) {
414
+ this.log(`[WebSocket] Failed to broadcast to ${id}:`, error);
415
+ // Don't abort -- continue sending to remaining clients
416
+ }
237
417
  }
238
418
  });
239
419
  }
240
420
 
421
+ /** Returns the number of currently connected clients. */
422
+ public get clientCount(): number {
423
+ return this.clients.size;
424
+ }
425
+
241
426
  // --- Private Helpers ---
242
427
 
243
428
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
244
- private handleHandshake(socket: any, header: string) {
429
+ private handleHandshake(managed: ManagedSocket, header: string) {
245
430
  this.log("[WebSocket] Handshake request header:", JSON.stringify(header));
246
431
  const keyMatch = header.match(/Sec-WebSocket-Key: (.+)/);
247
432
  if (!keyMatch) {
248
433
  console.error("[WebSocket] Handshake failed: No Sec-WebSocket-Key found");
249
- socket.destroy();
434
+ managed.socket.destroy();
435
+ return;
436
+ }
437
+
438
+ // Validate Sec-WebSocket-Version (RFC 6455 Section 4.2.1)
439
+ const versionMatch = header.match(/Sec-WebSocket-Version: (\d+)/);
440
+ if (versionMatch && versionMatch[1] !== "13") {
441
+ console.error(
442
+ `[WebSocket] Unsupported WebSocket version: ${versionMatch[1]}`,
443
+ );
444
+ managed.socket.destroy();
250
445
  return;
251
446
  }
252
447
 
@@ -269,15 +464,15 @@ export class GameWebSocketServer extends EventEmitter {
269
464
  "[WebSocket] Sending Handshake Response:",
270
465
  JSON.stringify(response),
271
466
  );
272
- socket.write(response);
467
+ managed.socket.write(response);
273
468
 
274
- // Assign ID and store
275
- socket.id = this.generateSocketId();
276
- this.clients.set(socket.id, socket);
277
- this.emit("connection", socket.id);
469
+ // Assign cryptographically random ID and store
470
+ managed.id = generateId();
471
+ this.clients.set(managed.id, managed);
472
+ this.emit("connection", managed.id);
278
473
  } catch (error) {
279
474
  console.error("[WebSocket] Handshake error:", error);
280
- socket.destroy();
475
+ managed.socket.destroy();
281
476
  }
282
477
  }
283
478
 
@@ -310,13 +505,19 @@ export class GameWebSocketServer extends EventEmitter {
310
505
  // Read 64-bit length. For safety, only use the lower 32 bits.
311
506
  const highBits = buffer.readUInt32BE(2);
312
507
  if (highBits > 0) {
313
- // Payload > 4GB -- reject
314
- throw new Error("Frame payload too large");
508
+ throw new Error("Frame payload too large (exceeds 4 GB)");
315
509
  }
316
510
  payloadLength = buffer.readUInt32BE(6);
317
511
  headerLength = 10;
318
512
  }
319
513
 
514
+ // Enforce max frame size to prevent memory exhaustion attacks
515
+ if (payloadLength > this.maxFrameSize) {
516
+ throw new Error(
517
+ `Frame payload (${payloadLength} bytes) exceeds maximum allowed size (${this.maxFrameSize} bytes)`,
518
+ );
519
+ }
520
+
320
521
  const maskLength = isMasked ? 4 : 0;
321
522
  const totalFrameLength = headerLength + maskLength + payloadLength;
322
523
 
@@ -325,8 +526,8 @@ export class GameWebSocketServer extends EventEmitter {
325
526
 
326
527
  let payload: Buffer;
327
528
  if (isMasked) {
328
- const mask = buffer.slice(headerLength, headerLength + 4);
329
- const maskedPayload = buffer.slice(
529
+ const mask = buffer.subarray(headerLength, headerLength + 4);
530
+ const maskedPayload = buffer.subarray(
330
531
  headerLength + 4,
331
532
  headerLength + 4 + payloadLength,
332
533
  );
@@ -336,7 +537,7 @@ export class GameWebSocketServer extends EventEmitter {
336
537
  }
337
538
  } else {
338
539
  payload = Buffer.from(
339
- buffer.slice(headerLength, headerLength + payloadLength),
540
+ buffer.subarray(headerLength, headerLength + payloadLength),
340
541
  );
341
542
  }
342
543
 
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
- }