@couch-kit/host 1.3.1 → 1.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/lib/provider.d.ts CHANGED
@@ -9,6 +9,8 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
9
9
  devServerUrl?: string;
10
10
  staticDir?: string;
11
11
  debug?: boolean;
12
+ /** Timeout (ms) before a disconnected player is permanently removed (default: 5 minutes). */
13
+ disconnectTimeout?: number;
12
14
  /** Called when a player successfully joins. */
13
15
  onPlayerJoined?: (playerId: string, name: string) => void;
14
16
  /** Called when a player disconnects. */
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAGb,MAAM,iBAAiB,CAAC;AAEzB,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACrE,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,+CAA+C;IAC/C,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,wCAAwC;IACxC,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,yCAAyC;IACzC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED,UAAU,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACpE,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,KAAK,GAAG,IAAI,CAAC;CAC3B;AA4CD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EAAE,EACxE,QAAQ,EACR,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CAC9B,qBAsTA;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
1
+ {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAUL,KAAK,UAAU,EACf,KAAK,OAAO,EAGb,MAAM,iBAAiB,CAAC;AAQzB,MAAM,WAAW,cAAc,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACrE,YAAY,EAAE,CAAC,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,6FAA6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,+CAA+C;IAC/C,cAAc,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1D,wCAAwC;IACxC,YAAY,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC1C,yCAAyC;IACzC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;CAClC;AAED,UAAU,oBAAoB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACpE,KAAK,EAAE,CAAC,CAAC;IACT,QAAQ,EAAE,CAAC,MAAM,EAAE,CAAC,KAAK,IAAI,CAAC;IAC9B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,WAAW,EAAE,KAAK,GAAG,IAAI,CAAC;CAC3B;AA4CD;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EAAE,EACxE,QAAQ,EACR,MAAM,GACP,EAAE;IACD,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAC;IAC1B,MAAM,EAAE,cAAc,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;CAC9B,qBAmaA;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
@@ -1,9 +1,8 @@
1
1
  /**
2
- * Lightweight WebSocket Server Implementation
3
- * Built on top of react-native-tcp-socket
2
+ * WebSocket Server Implementation using nitro-http
4
3
  *
5
- * Supports: text frames, close frames, ping/pong, multi-frame TCP packets,
6
- * server-side keepalive, frame size limits, and robust buffer management per RFC 6455.
4
+ * Supports: bidirectional messaging, connection/disconnect events,
5
+ * and integration with react-native-nitro-http-server's WebSocket plugin.
7
6
  */
8
7
  import { EventEmitter } from "./event-emitter";
9
8
  export interface WebSocketConfig {
@@ -29,20 +28,10 @@ export declare class GameWebSocketServer extends EventEmitter<WebSocketServerEve
29
28
  private clients;
30
29
  private port;
31
30
  private debug;
32
- private maxFrameSize;
33
- private keepaliveInterval;
34
- private keepaliveTimeout;
35
- private keepaliveTimer;
36
31
  constructor(config: WebSocketConfig);
37
32
  private log;
38
- start(): void;
39
- private startKeepalive;
40
- private processFrames;
41
- /**
42
- * Gracefully stop the server.
43
- * Sends close frames to all clients before destroying connections.
44
- */
45
- stop(): void;
33
+ start(): Promise<void>;
34
+ stop(): Promise<void>;
46
35
  /**
47
36
  * Send data to a specific client by socket ID.
48
37
  * Silently ignores unknown socket IDs and write errors.
@@ -50,16 +39,10 @@ export declare class GameWebSocketServer extends EventEmitter<WebSocketServerEve
50
39
  send(socketId: string, data: unknown): void;
51
40
  /**
52
41
  * Broadcast data to all connected clients.
53
- * Wraps each write in try/catch so a single dead socket doesn't skip remaining clients.
42
+ * Wraps each send in try/catch so a single failed send doesn't skip remaining clients.
54
43
  */
55
44
  broadcast(data: unknown, excludeId?: string): void;
56
45
  /** Returns the number of currently connected clients. */
57
46
  get clientCount(): number;
58
- private handleHandshake;
59
- private generateAcceptKey;
60
- private decodeFrame;
61
- private encodeFrame;
62
- private encodeControlFrame;
63
- private buildFrame;
64
47
  }
65
48
  //# sourceMappingURL=websocket.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAY/C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9C,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAC;AAwBF,qBAAa,mBAAoB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IAC1E,OAAO,CAAC,MAAM,CAA0D;IACxE,OAAO,CAAC,OAAO,CAAyC;IACxD,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,KAAK,CAAU;IACvB,OAAO,CAAC,YAAY,CAAS;IAC7B,OAAO,CAAC,iBAAiB,CAAS;IAClC,OAAO,CAAC,gBAAgB,CAAS;IACjC,OAAO,CAAC,cAAc,CAA+C;gBAEzD,MAAM,EAAE,eAAe;IASnC,OAAO,CAAC,GAAG;IAMJ,KAAK;IAkFZ,OAAO,CAAC,cAAc;IAkCtB,OAAO,CAAC,aAAa;IA6GrB;;;OAGG;IACI,IAAI;IAqCX;;;OAGG;IACI,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO;IAgB3C;;;OAGG;IACI,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM;IAclD,yDAAyD;IACzD,IAAW,WAAW,IAAI,MAAM,CAE/B;IAID,OAAO,CAAC,eAAe;IAkDvB,OAAO,CAAC,iBAAiB;IAQzB,OAAO,CAAC,WAAW;IA4DnB,OAAO,CAAC,WAAW;IAKnB,OAAO,CAAC,kBAAkB;IAI1B,OAAO,CAAC,UAAU;CA2BnB"}
1
+ {"version":3,"file":"websocket.d.ts","sourceRoot":"","sources":["../src/websocket.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH,OAAO,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAG/C,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,mEAAmE;IACnE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6EAA6E;IAC7E,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED,8CAA8C;AAC9C,MAAM,MAAM,qBAAqB,GAAG;IAClC,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,CAAC,CAAC;IAC9C,UAAU,EAAE,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;IAC/B,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IAC1B,KAAK,EAAE,CAAC,KAAK,EAAE,KAAK,CAAC,CAAC;CACvB,CAAC;AAOF,qBAAa,mBAAoB,SAAQ,YAAY,CAAC,qBAAqB,CAAC;IAC1E,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,OAAO,CAA2C;IAC1D,OAAO,CAAC,IAAI,CAAS;IACrB,OAAO,CAAC,KAAK,CAAU;gBAEX,MAAM,EAAE,eAAe;IASnC,OAAO,CAAC,GAAG;IAME,KAAK;IA6FL,IAAI;IAoBjB;;;OAGG;IACI,IAAI,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,OAAO;IAsB3C;;;OAGG;IACI,SAAS,CAAC,IAAI,EAAE,OAAO,EAAE,SAAS,CAAC,EAAE,MAAM;IAoBlD,yDAAyD;IACzD,IAAW,WAAW,IAAI,MAAM,CAE/B;CACF"}
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@couch-kit/host",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
+ "publishConfig": {
5
+ "access": "public",
6
+ "provenance": true
7
+ },
4
8
  "description": "React Native host for local multiplayer party games on Android TV — WebSocket server, state management, and static file serving",
5
9
  "license": "MIT",
6
10
  "repository": {
@@ -51,7 +55,7 @@
51
55
  "prepublishOnly": "bun run build"
52
56
  },
53
57
  "dependencies": {
54
- "@couch-kit/core": "0.5.0",
58
+ "@couch-kit/core": "0.6.0",
55
59
  "buffer": "^6.0.3",
56
60
  "js-sha1": "^0.7.0",
57
61
  "react-native-nitro-http-server": "^1.5.4"
@@ -69,7 +73,6 @@
69
73
  "react-native-nitro-modules": ">=0.33.0",
70
74
  "expo": ">=51.0.0",
71
75
  "expo-file-system": ">=17.0.0",
72
- "expo-network": ">=7.0.0",
73
- "react-native-tcp-socket": ">=6.0.0"
76
+ "expo-network": ">=7.0.0"
74
77
  }
75
78
  }
package/src/provider.tsx CHANGED
@@ -14,8 +14,10 @@ import {
14
14
  InternalActionTypes,
15
15
  DEFAULT_HTTP_PORT,
16
16
  DEFAULT_WS_PORT_OFFSET,
17
+ DEFAULT_DISCONNECT_TIMEOUT,
17
18
  createGameReducer,
18
19
  derivePlayerId,
20
+ derivePlayerIdLegacy,
19
21
  isValidSecret,
20
22
  type IGameState,
21
23
  type IAction,
@@ -23,6 +25,12 @@ import {
23
25
  type ClientMessage,
24
26
  } from "@couch-kit/core";
25
27
 
28
+ /** Maximum actions per rate-limit window. */
29
+ const RATE_LIMIT_MAX = 60;
30
+
31
+ /** Rate-limit window duration (ms). */
32
+ const RATE_LIMIT_WINDOW = 1000;
33
+
26
34
  export interface GameHostConfig<S extends IGameState, A extends IAction> {
27
35
  initialState: S;
28
36
  reducer: (state: S, action: A) => S;
@@ -32,6 +40,8 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
32
40
  devServerUrl?: string;
33
41
  staticDir?: string; // Override the default www directory path (required on Android)
34
42
  debug?: boolean;
43
+ /** Timeout (ms) before a disconnected player is permanently removed (default: 5 minutes). */
44
+ disconnectTimeout?: number;
35
45
  /** Called when a player successfully joins. */
36
46
  onPlayerJoined?: (playerId: string, name: string) => void;
37
47
  /** Called when a player disconnects. */
@@ -125,23 +135,36 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
125
135
  stateRef.current = state;
126
136
  }, [state]);
127
137
 
128
- // Send WELCOME messages after state has settled (post-render).
138
+ // Send WELCOME/RECONNECTED messages after state has settled (post-render).
129
139
  // This guarantees the joining player is included in the state snapshot.
130
140
  useEffect(() => {
131
141
  if (pendingWelcome.current.size === 0) return;
132
142
  if (!wsServer.current) return;
133
143
 
134
144
  const server = wsServer.current;
135
- for (const [socketId, playerId] of pendingWelcome.current) {
145
+ for (const [
146
+ socketId,
147
+ { playerId, isReconnect },
148
+ ] of pendingWelcome.current) {
136
149
  welcomedClients.current.add(socketId);
137
- server.send(socketId, {
138
- type: MessageTypes.WELCOME,
139
- payload: {
140
- playerId,
141
- state,
142
- serverTime: Date.now(),
143
- },
144
- });
150
+ if (isReconnect) {
151
+ server.send(socketId, {
152
+ type: MessageTypes.RECONNECTED,
153
+ payload: {
154
+ playerId,
155
+ state,
156
+ },
157
+ });
158
+ } else {
159
+ server.send(socketId, {
160
+ type: MessageTypes.WELCOME,
161
+ payload: {
162
+ playerId,
163
+ state,
164
+ serverTime: Date.now(),
165
+ },
166
+ });
167
+ }
145
168
  }
146
169
  pendingWelcome.current.clear();
147
170
  }, [state]);
@@ -178,14 +201,36 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
178
201
  // Track socket IDs that have received their WELCOME message
179
202
  const welcomedClients = useRef<Set<string>>(new Set());
180
203
 
181
- // Track socket IDs that need a WELCOME message after state settles
182
- const pendingWelcome = useRef<Map<string, string>>(new Map()); // socketId -> playerId
204
+ // Track socket IDs that need a WELCOME/RECONNECTED message after state settles
205
+ const pendingWelcome = useRef<
206
+ Map<string, { playerId: string; isReconnect: boolean }>
207
+ >(new Map());
208
+
209
+ // Cache: socketId -> playerId (avoids async derivation in hot paths)
210
+ const socketIdToPlayerId = useRef<Map<string, string>>(new Map());
211
+
212
+ // Track which players have finished loading assets
213
+ const assetsLoaded = useRef<Map<string, boolean>>(new Map());
214
+
215
+ // Queue of actions dispatched since last broadcast (for STATE_UPDATE.action)
216
+ const actionQueue = useRef<unknown[]>([]);
217
+
218
+ // Rate limiting: track action count per socket
219
+ const rateLimits = useRef<
220
+ Map<string, { count: number; windowStart: number }>
221
+ >(new Map());
183
222
 
184
223
  useEffect(() => {
185
224
  const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
186
225
  const server = new GameWebSocketServer({ port, debug: config.debug });
187
226
 
188
- server.start();
227
+ // Start the WebSocket server asynchronously
228
+ server.start().catch((error) => {
229
+ if (configRef.current.debug) {
230
+ console.error("[GameHost] Failed to start WebSocket server:", error);
231
+ }
232
+ configRef.current.onError?.(error);
233
+ });
189
234
  wsServer.current = server;
190
235
 
191
236
  server.on("listening", (p) => {
@@ -234,39 +279,69 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
234
279
  return;
235
280
  }
236
281
 
237
- const playerId = derivePlayerId(secret);
238
-
239
- // Update session maps
240
- sessions.current.set(secret, socketId);
241
- reverseMap.current.set(socketId, secret);
242
-
243
- // Cancel any pending cleanup timer for this player
244
- const existingTimer = cleanupTimers.current.get(playerId);
245
- if (existingTimer) {
246
- clearTimeout(existingTimer);
247
- cleanupTimers.current.delete(playerId);
248
- }
249
-
250
- // Check if this is a returning player
251
- const existingPlayer = stateRef.current.players[playerId];
252
- if (existingPlayer) {
253
- // Reconnection restore existing player
254
- dispatch({
255
- type: InternalActionTypes.PLAYER_RECONNECTED,
256
- payload: { playerId },
257
- } as InternalAction<S>);
258
- } else {
259
- // New player
260
- dispatch({
261
- type: InternalActionTypes.PLAYER_JOINED,
262
- payload: { id: playerId, ...payload },
263
- } as InternalAction<S>);
264
- }
265
-
266
- // Queue WELCOME
267
- pendingWelcome.current.set(socketId, playerId);
268
-
269
- configRef.current.onPlayerJoined?.(playerId, payload.name);
282
+ // Async dual-derivation: try SHA-256 first, fall back to legacy
283
+ derivePlayerId(secret).then((hashedId) => {
284
+ let playerId = hashedId;
285
+
286
+ // Migration: if a player exists under the legacy ID, use that instead
287
+ const legacyId = derivePlayerIdLegacy(secret);
288
+ if (
289
+ !stateRef.current.players[playerId] &&
290
+ stateRef.current.players[legacyId]
291
+ ) {
292
+ playerId = legacyId;
293
+ }
294
+
295
+ // Cache the resolved playerId for this socket
296
+ socketIdToPlayerId.current.set(socketId, playerId);
297
+
298
+ // Update session maps
299
+ sessions.current.set(secret, socketId);
300
+ reverseMap.current.set(socketId, secret);
301
+
302
+ // Cancel any pending cleanup timer for this player
303
+ const existingTimer = cleanupTimers.current.get(playerId);
304
+ if (existingTimer) {
305
+ clearTimeout(existingTimer);
306
+ cleanupTimers.current.delete(playerId);
307
+ }
308
+
309
+ // Check if this is a returning player
310
+ const existingPlayer = stateRef.current.players[playerId];
311
+ if (existingPlayer) {
312
+ // Reconnection — restore existing player
313
+ dispatch({
314
+ type: InternalActionTypes.PLAYER_RECONNECTED,
315
+ payload: { playerId },
316
+ } as InternalAction<S>);
317
+
318
+ // Reset assets loaded status on reconnect
319
+ assetsLoaded.current.set(playerId, false);
320
+
321
+ // Queue RECONNECTED message
322
+ pendingWelcome.current.set(socketId, {
323
+ playerId,
324
+ isReconnect: true,
325
+ });
326
+ } else {
327
+ // New player
328
+ dispatch({
329
+ type: InternalActionTypes.PLAYER_JOINED,
330
+ payload: { id: playerId, ...payload },
331
+ } as InternalAction<S>);
332
+
333
+ // Initialize assets loaded status
334
+ assetsLoaded.current.set(playerId, false);
335
+
336
+ // Queue WELCOME message
337
+ pendingWelcome.current.set(socketId, {
338
+ playerId,
339
+ isReconnect: false,
340
+ });
341
+ }
342
+
343
+ configRef.current.onPlayerJoined?.(playerId, payload.name);
344
+ });
270
345
  break;
271
346
  }
272
347
 
@@ -296,12 +371,32 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
296
371
  });
297
372
  return;
298
373
  }
299
- // Resolve playerId from socketId
300
- const actionSecret = reverseMap.current.get(socketId);
301
- const resolvedPlayerId = actionSecret
302
- ? derivePlayerId(actionSecret)
303
- : undefined;
374
+
375
+ // Rate limiting
376
+ const now = Date.now();
377
+ let rateInfo = rateLimits.current.get(socketId);
378
+ if (!rateInfo || now - rateInfo.windowStart > RATE_LIMIT_WINDOW) {
379
+ rateInfo = { count: 0, windowStart: now };
380
+ rateLimits.current.set(socketId, rateInfo);
381
+ }
382
+ rateInfo.count++;
383
+ if (rateInfo.count > RATE_LIMIT_MAX) {
384
+ if (configRef.current.debug)
385
+ console.warn(`[GameHost] Rate limited ${socketId}`);
386
+ server.send(socketId, {
387
+ type: MessageTypes.ERROR,
388
+ payload: {
389
+ code: "RATE_LIMITED",
390
+ message: "Too many actions, slow down",
391
+ },
392
+ });
393
+ return;
394
+ }
395
+
396
+ // Use cached playerId (populated at JOIN time)
397
+ const resolvedPlayerId = socketIdToPlayerId.current.get(socketId);
304
398
  dispatch({ ...actionPayload, playerId: resolvedPlayerId });
399
+ actionQueue.current.push(actionPayload);
305
400
  break;
306
401
  }
307
402
 
@@ -315,6 +410,16 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
315
410
  },
316
411
  });
317
412
  break;
413
+
414
+ case MessageTypes.ASSETS_LOADED: {
415
+ const loadedPlayerId = socketIdToPlayerId.current.get(socketId);
416
+ if (loadedPlayerId) {
417
+ assetsLoaded.current.set(loadedPlayerId, true);
418
+ if (configRef.current.debug)
419
+ console.log(`[GameHost] Assets loaded for ${loadedPlayerId}`);
420
+ }
421
+ break;
422
+ }
318
423
  }
319
424
  });
320
425
 
@@ -324,13 +429,21 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
324
429
 
325
430
  welcomedClients.current.delete(socketId);
326
431
 
327
- // Resolve socketId -> secret -> playerId
432
+ // Use cached playerId
433
+ const playerId = socketIdToPlayerId.current.get(socketId);
434
+ socketIdToPlayerId.current.delete(socketId);
435
+
436
+ // Clean up reverse map
328
437
  const secret = reverseMap.current.get(socketId);
329
438
  reverseMap.current.delete(socketId);
330
439
 
331
- if (!secret) return; // Unknown socket, nothing to do
440
+ // Clean up rate limits
441
+ rateLimits.current.delete(socketId);
442
+
443
+ if (!playerId || !secret) return; // Unknown socket, nothing to do
332
444
 
333
- const playerId = derivePlayerId(secret);
445
+ // Clean up assets loaded tracking
446
+ assetsLoaded.current.delete(playerId);
334
447
 
335
448
  // RACE GUARD: Only mark as left if this socket is still the active one for this secret
336
449
  if (sessions.current.get(secret) !== socketId) {
@@ -346,18 +459,17 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
346
459
 
347
460
  configRef.current.onPlayerLeft?.(playerId);
348
461
 
349
- // Start stale player cleanup timer (5 minutes default)
350
- const timer = setTimeout(
351
- () => {
352
- cleanupTimers.current.delete(playerId);
353
- sessions.current.delete(secret);
354
- dispatch({
355
- type: InternalActionTypes.PLAYER_REMOVED,
356
- payload: { playerId },
357
- } as InternalAction<S>);
358
- },
359
- 5 * 60 * 1000,
360
- );
462
+ // Start stale player cleanup timer
463
+ const timeout =
464
+ configRef.current.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT;
465
+ const timer = setTimeout(() => {
466
+ cleanupTimers.current.delete(playerId);
467
+ sessions.current.delete(secret);
468
+ dispatch({
469
+ type: InternalActionTypes.PLAYER_REMOVED,
470
+ payload: { playerId },
471
+ } as InternalAction<S>);
472
+ }, timeout);
361
473
  cleanupTimers.current.set(playerId, timer);
362
474
  });
363
475
 
@@ -383,11 +495,18 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
383
495
 
384
496
  const broadcastState = useCallback(() => {
385
497
  if (wsServer.current) {
498
+ const actions = actionQueue.current;
499
+ actionQueue.current = [];
386
500
  wsServer.current.broadcast({
387
501
  type: MessageTypes.STATE_UPDATE,
388
502
  payload: {
389
503
  newState: stateRef.current,
390
504
  timestamp: Date.now(),
505
+ ...(actions.length === 1
506
+ ? { action: actions[0] }
507
+ : actions.length > 1
508
+ ? { action: actions }
509
+ : {}),
391
510
  },
392
511
  });
393
512
  }
package/src/websocket.ts CHANGED
@@ -1,24 +1,17 @@
1
1
  /**
2
- * Lightweight WebSocket Server Implementation
3
- * Built on top of react-native-tcp-socket
2
+ * WebSocket Server Implementation using nitro-http
4
3
  *
5
- * Supports: text frames, close frames, ping/pong, multi-frame TCP packets,
6
- * server-side keepalive, frame size limits, and robust buffer management per RFC 6455.
4
+ * Supports: bidirectional messaging, connection/disconnect events,
5
+ * and integration with react-native-nitro-http-server's WebSocket plugin.
7
6
  */
8
7
 
9
- import TcpSocket from "react-native-tcp-socket";
10
- import type { TcpSocketInstance } from "./declarations";
8
+ import { ConfigServer } from "react-native-nitro-http-server";
9
+ import type {
10
+ ServerWebSocket,
11
+ WebSocketConnectionRequest,
12
+ } from "react-native-nitro-http-server";
11
13
  import { EventEmitter } from "./event-emitter";
12
- import { Buffer } from "buffer";
13
- import { sha1 } from "js-sha1";
14
- import {
15
- generateId,
16
- MAX_FRAME_SIZE,
17
- KEEPALIVE_INTERVAL,
18
- KEEPALIVE_TIMEOUT,
19
- } from "@couch-kit/core";
20
- import { appendToBuffer, compactBuffer } from "./buffer-utils";
21
- import type { ManagedSocket } from "./buffer-utils";
14
+ import { generateId } from "@couch-kit/core";
22
15
 
23
16
  export interface WebSocketConfig {
24
17
  port: number;
@@ -40,45 +33,24 @@ export type WebSocketServerEvents = {
40
33
  error: [error: Error];
41
34
  };
42
35
 
43
- // WebSocket opcodes (RFC 6455 Section 5.2)
44
- const OPCODE = {
45
- CONTINUATION: 0x0,
46
- TEXT: 0x1,
47
- BINARY: 0x2,
48
- CLOSE: 0x8,
49
- PING: 0x9,
50
- PONG: 0xa,
51
- } as const;
52
-
53
- // Simple WebSocket Frame Parser/Builder
54
- const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
55
-
56
- /** Initial capacity for per-client receive buffers. */
57
- const INITIAL_BUFFER_CAPACITY = 4096;
58
-
59
- interface DecodedFrame {
60
- opcode: number;
61
- payload: Buffer;
62
- bytesConsumed: number;
36
+ interface WebSocketClient {
37
+ id: string;
38
+ ws: ServerWebSocket;
63
39
  }
64
40
 
65
41
  export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
66
- private server: ReturnType<typeof TcpSocket.createServer> | null = null;
67
- private clients: Map<string, ManagedSocket> = new Map();
42
+ private server: ConfigServer | null = null;
43
+ private clients: Map<string, WebSocketClient> = new Map();
68
44
  private port: number;
69
45
  private debug: boolean;
70
- private maxFrameSize: number;
71
- private keepaliveInterval: number;
72
- private keepaliveTimeout: number;
73
- private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
74
46
 
75
47
  constructor(config: WebSocketConfig) {
76
48
  super();
77
49
  this.port = config.port;
78
50
  this.debug = !!config.debug;
79
- this.maxFrameSize = config.maxFrameSize ?? MAX_FRAME_SIZE;
80
- this.keepaliveInterval = config.keepaliveInterval ?? KEEPALIVE_INTERVAL;
81
- this.keepaliveTimeout = config.keepaliveTimeout ?? KEEPALIVE_TIMEOUT;
51
+ // Note: maxFrameSize, keepaliveInterval, and keepaliveTimeout are not directly
52
+ // configurable in nitro-http WebSocket plugin, but the underlying implementation
53
+ // handles these concerns automatically.
82
54
  }
83
55
 
84
56
  private log(...args: unknown[]) {
@@ -87,269 +59,116 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
87
59
  }
88
60
  }
89
61
 
90
- public start() {
62
+ public async start() {
91
63
  this.log(`[WebSocket] Starting server on port ${this.port}...`);
92
64
 
93
- this.server = TcpSocket.createServer((rawSocket: TcpSocketInstance) => {
94
- const addrInfo = rawSocket.address();
95
- const remoteAddr =
96
- addrInfo && "address" in addrInfo ? addrInfo.address : "unknown";
97
- this.log(`[WebSocket] New connection from ${remoteAddr}`);
98
-
99
- const managed: ManagedSocket = {
100
- socket: rawSocket,
101
- id: "",
102
- isHandshakeComplete: false,
103
- buffer: Buffer.alloc(INITIAL_BUFFER_CAPACITY),
104
- bufferLength: 0,
105
- lastPong: Date.now(),
106
- };
107
-
108
- rawSocket.on("data", (data: Buffer | string) => {
109
- this.log(
110
- `[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
111
- );
112
- // Append new data using growing buffer strategy (avoids Buffer.concat per event)
113
- const incoming = typeof data === "string" ? Buffer.from(data) : data;
114
- appendToBuffer(managed, incoming);
115
-
116
- // Handshake not yet performed?
117
- if (!managed.isHandshakeComplete) {
118
- const header = managed.buffer.toString(
119
- "utf8",
120
- 0,
121
- managed.bufferLength,
122
- );
123
- const endOfHeader = header.indexOf("\r\n\r\n");
124
- if (endOfHeader !== -1) {
125
- this.handleHandshake(managed, header);
126
- // Compact buffer past the handshake header
127
- const headerByteLength = Buffer.byteLength(
128
- header.substring(0, endOfHeader + 4),
129
- "utf8",
130
- );
131
- compactBuffer(managed, headerByteLength);
132
- managed.isHandshakeComplete = true;
133
- // Fall through to process any remaining frames below
134
- } else {
135
- return;
136
- }
137
- }
138
-
139
- // Process all complete frames in the buffer
140
- this.processFrames(managed);
141
- });
142
-
143
- rawSocket.on("error", (error: Error) => {
144
- this.emit("error", error);
145
- });
146
-
147
- rawSocket.on("close", () => {
148
- if (managed.id) {
149
- this.clients.delete(managed.id);
150
- this.emit("disconnect", managed.id);
151
- }
152
- });
153
- });
154
-
155
- // Handle server-level errors (e.g., port already in use)
156
- this.server.on("error", (error: Error) => {
157
- this.log("[WebSocket] Server error:", error);
158
- this.emit("error", error);
159
- });
160
-
161
- this.server.listen({ port: this.port, host: "0.0.0.0" }, () => {
162
- this.log(`[WebSocket] Server listening on 0.0.0.0:${this.port}`);
163
- this.emit("listening", this.port);
164
- });
165
-
166
- // Start keepalive pings if enabled
167
- if (this.keepaliveInterval > 0) {
168
- this.startKeepalive();
169
- }
170
- }
171
-
172
- private startKeepalive() {
173
- this.keepaliveTimer = setInterval(() => {
174
- const now = Date.now();
175
- const pingFrame = this.encodeControlFrame(OPCODE.PING, Buffer.alloc(0));
176
-
177
- for (const [id, managed] of this.clients) {
178
- // Check if previous keepalive timed out
179
- if (
180
- now - managed.lastPong >
181
- this.keepaliveInterval + this.keepaliveTimeout
182
- ) {
183
- this.log(`[WebSocket] Keepalive timeout for ${id}, disconnecting`);
184
- try {
185
- managed.socket.destroy();
186
- } catch (error) {
187
- this.log(
188
- "[WebSocket] Socket already destroyed during keepalive cleanup:",
189
- error,
190
- );
191
- }
192
- this.clients.delete(id);
193
- this.emit("disconnect", id);
194
- continue;
195
- }
196
-
197
- try {
198
- managed.socket.write(pingFrame);
199
- } catch (error) {
200
- this.log("[WebSocket] Failed to send keepalive ping:", error);
201
- }
202
- }
203
- }, this.keepaliveInterval);
204
- }
205
-
206
- private processFrames(managed: ManagedSocket) {
207
- let offset = 0;
65
+ this.server = new ConfigServer();
208
66
 
209
- while (offset < managed.bufferLength) {
210
- // Create a view of the unconsumed portion for decoding
211
- const view = Buffer.from(
212
- managed.buffer.buffer,
213
- managed.buffer.byteOffset + offset,
214
- managed.bufferLength - offset,
215
- );
216
- let frame: DecodedFrame | null;
217
- try {
218
- frame = this.decodeFrame(view);
219
- } catch (error) {
220
- // Frame too large or malformed -- disconnect the client
221
- this.log(`[WebSocket] Frame error from ${managed.id}:`, error);
222
- try {
223
- managed.socket.destroy();
224
- } catch (destroyError) {
225
- this.log(
226
- "[WebSocket] Socket already destroyed after frame error:",
227
- destroyError,
228
- );
229
- }
230
- return;
231
- }
67
+ // Register WebSocket handler for the /ws path
68
+ this.server.onWebSocket(
69
+ "/ws",
70
+ (ws: ServerWebSocket, request: WebSocketConnectionRequest) => {
71
+ const socketId = generateId();
72
+ this.log(`[WebSocket] New connection: ${socketId}`, request);
232
73
 
233
- if (!frame) {
234
- // Incomplete frame -- keep remaining bytes, wait for more data
235
- break;
236
- }
74
+ // Store the client
75
+ this.clients.set(socketId, { id: socketId, ws });
237
76
 
238
- // Advance past the consumed frame
239
- offset += frame.bytesConsumed;
77
+ // Emit connection event
78
+ this.emit("connection", socketId);
240
79
 
241
- // Handle frame by opcode
242
- switch (frame.opcode) {
243
- case OPCODE.TEXT: {
80
+ // Handle incoming messages
81
+ ws.onmessage = (event: { data: string | ArrayBuffer }) => {
244
82
  try {
245
- const message = JSON.parse(frame.payload.toString("utf8"));
246
- this.emit("message", managed.id, message);
83
+ const data =
84
+ typeof event.data === "string"
85
+ ? event.data
86
+ : new TextDecoder().decode(event.data);
87
+ const message = JSON.parse(data);
88
+ this.emit("message", socketId, message);
247
89
  } catch (error) {
248
- // Corrupt JSON in a complete frame -- discard this frame, continue processing
249
90
  this.log(
250
- `[WebSocket] Invalid JSON from ${managed.id}, discarding frame:`,
91
+ `[WebSocket] Invalid JSON from ${socketId}, discarding:`,
251
92
  error,
252
93
  );
253
94
  }
254
- break;
255
- }
256
-
257
- case OPCODE.CLOSE: {
258
- this.log(`[WebSocket] Close frame from ${managed.id}`);
259
- // Send close frame back (RFC 6455 Section 5.5.1)
260
- const closeFrame = Buffer.alloc(2);
261
- closeFrame[0] = 0x88; // FIN + Close opcode
262
- closeFrame[1] = 0x00; // No payload
263
- try {
264
- managed.socket.write(closeFrame);
265
- } catch (error) {
266
- this.log("[WebSocket] Failed to send close frame:", error);
267
- }
268
- managed.socket.destroy();
269
- break;
270
- }
271
-
272
- case OPCODE.PING: {
273
- this.log(`[WebSocket] Ping from ${managed.id}`);
274
- // Respond with pong containing the same payload (RFC 6455 Section 5.5.3)
275
- const pongFrame = this.encodeControlFrame(OPCODE.PONG, frame.payload);
276
- try {
277
- managed.socket.write(pongFrame);
278
- } catch (error) {
279
- this.log("[WebSocket] Failed to send pong:", error);
280
- }
281
- break;
282
- }
283
-
284
- case OPCODE.PONG: {
285
- // Update last-seen pong time for keepalive tracking
286
- managed.lastPong = Date.now();
287
- this.log(`[WebSocket] Pong from ${managed.id}`);
288
- break;
289
- }
290
-
291
- case OPCODE.BINARY: {
292
- // Binary frames not supported -- log and discard
95
+ };
96
+
97
+ // Handle disconnect
98
+ ws.onclose = (event: {
99
+ code: number;
100
+ reason: string;
101
+ wasClean: boolean;
102
+ }) => {
293
103
  this.log(
294
- `[WebSocket] Binary frame from ${managed.id}, not supported -- discarding`,
104
+ `[WebSocket] Client disconnected: ${socketId}`,
105
+ event.code,
106
+ event.reason,
295
107
  );
296
- break;
297
- }
298
-
299
- default: {
300
- this.log(
301
- `[WebSocket] Unknown opcode 0x${frame.opcode.toString(16)} from ${managed.id}, discarding`,
108
+ this.clients.delete(socketId);
109
+ this.emit("disconnect", socketId);
110
+ };
111
+
112
+ // Handle errors
113
+ ws.onerror = (event: { message: string }) => {
114
+ this.log(`[WebSocket] Error on ${socketId}:`, event.message);
115
+ this.emit(
116
+ "error",
117
+ new Error(`WebSocket error: ${event.message}`),
302
118
  );
303
- break;
304
- }
305
- }
119
+ };
120
+ },
121
+ );
306
122
 
307
- // If socket was destroyed (e.g., close frame), stop processing
308
- if (managed.socket.destroyed) break;
123
+ // Start the server with WebSocket plugin
124
+ try {
125
+ await this.server.start(
126
+ this.port,
127
+ async () => {
128
+ // Dummy HTTP handler - we're only using WebSocket functionality
129
+ return {
130
+ statusCode: 404,
131
+ body: "WebSocket server - use ws:// protocol",
132
+ };
133
+ },
134
+ {
135
+ mounts: [
136
+ {
137
+ type: "websocket",
138
+ path: "/ws",
139
+ },
140
+ ],
141
+ },
142
+ "0.0.0.0", // Bind to all interfaces
143
+ );
144
+ this.emit("listening", this.port);
145
+ this.log(`[WebSocket] Server listening on port ${this.port}`);
146
+ } catch (error) {
147
+ this.log("[WebSocket] Failed to start server:", error);
148
+ this.emit(
149
+ "error",
150
+ error instanceof Error ? error : new Error(String(error)),
151
+ );
309
152
  }
310
-
311
- // Compact buffer: shift unconsumed bytes to the front
312
- compactBuffer(managed, offset);
313
153
  }
314
154
 
315
- /**
316
- * Gracefully stop the server.
317
- * Sends close frames to all clients before destroying connections.
318
- */
319
- public stop() {
320
- // Stop keepalive timer
321
- if (this.keepaliveTimer) {
322
- clearInterval(this.keepaliveTimer);
323
- this.keepaliveTimer = null;
324
- }
325
-
155
+ public async stop() {
326
156
  if (this.server) {
327
- // Send close frames to all clients before destroying
328
- const closeFrame = Buffer.alloc(2);
329
- closeFrame[0] = 0x88; // FIN + Close opcode
330
- closeFrame[1] = 0x00; // No payload
331
-
332
- this.clients.forEach((managed) => {
333
- try {
334
- managed.socket.write(closeFrame);
335
- } catch (error) {
336
- this.log(
337
- "[WebSocket] Failed to send close frame during shutdown:",
338
- error,
339
- );
340
- }
157
+ // Close all client connections
158
+ for (const [, client] of this.clients) {
341
159
  try {
342
- managed.socket.destroy();
160
+ await client.ws.close(1000, "Server shutting down");
343
161
  } catch (error) {
344
162
  this.log(
345
- "[WebSocket] Socket already destroyed during shutdown:",
163
+ "[WebSocket] Failed to close connection during shutdown:",
346
164
  error,
347
165
  );
348
166
  }
349
- });
167
+ }
350
168
 
351
169
  this.clients.clear();
352
- this.server.close();
170
+ await this.server.stop();
171
+ this.server = null;
353
172
  }
354
173
  }
355
174
 
@@ -358,13 +177,19 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
358
177
  * Silently ignores unknown socket IDs and write errors.
359
178
  */
360
179
  public send(socketId: string, data: unknown) {
361
- const managed = this.clients.get(socketId);
362
- if (managed) {
180
+ const client = this.clients.get(socketId);
181
+ if (client) {
363
182
  try {
364
- const frame = this.encodeFrame(JSON.stringify(data));
365
- managed.socket.write(frame);
183
+ const message = JSON.stringify(data);
184
+ client.ws.send(message).catch((error: Error) => {
185
+ this.log(`[WebSocket] Failed to send to ${socketId}:`, error);
186
+ this.emit(
187
+ "error",
188
+ error instanceof Error ? error : new Error(String(error)),
189
+ );
190
+ });
366
191
  } catch (error) {
367
- this.log(`[WebSocket] Failed to send to ${socketId}:`, error);
192
+ this.log(`[WebSocket] Failed to serialize message:`, error);
368
193
  this.emit(
369
194
  "error",
370
195
  error instanceof Error ? error : new Error(String(error)),
@@ -375,181 +200,30 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
375
200
 
376
201
  /**
377
202
  * Broadcast data to all connected clients.
378
- * Wraps each write in try/catch so a single dead socket doesn't skip remaining clients.
203
+ * Wraps each send in try/catch so a single failed send doesn't skip remaining clients.
379
204
  */
380
205
  public broadcast(data: unknown, excludeId?: string) {
381
- const frame = this.encodeFrame(JSON.stringify(data));
382
- this.clients.forEach((managed, id) => {
383
- if (id !== excludeId) {
384
- try {
385
- managed.socket.write(frame);
386
- } catch (error) {
387
- this.log(`[WebSocket] Failed to broadcast to ${id}:`, error);
388
- // Don't abort -- continue sending to remaining clients
389
- }
390
- }
391
- });
392
- }
393
-
394
- /** Returns the number of currently connected clients. */
395
- public get clientCount(): number {
396
- return this.clients.size;
397
- }
398
-
399
- // --- Private Helpers ---
400
-
401
- private handleHandshake(managed: ManagedSocket, header: string) {
402
- this.log("[WebSocket] Handshake request header:", JSON.stringify(header));
403
- const keyMatch = header.match(/Sec-WebSocket-Key: (.+)/);
404
- if (!keyMatch) {
405
- console.error("[WebSocket] Handshake failed: No Sec-WebSocket-Key found");
406
- managed.socket.destroy();
407
- return;
408
- }
409
-
410
- // Validate Sec-WebSocket-Version (RFC 6455 Section 4.2.1)
411
- const versionMatch = header.match(/Sec-WebSocket-Version: (\d+)/);
412
- if (versionMatch && versionMatch[1] !== "13") {
413
- console.error(
414
- `[WebSocket] Unsupported WebSocket version: ${versionMatch[1]}`,
415
- );
416
- managed.socket.destroy();
417
- return;
418
- }
419
-
420
- const key = keyMatch[1].trim();
421
- this.log("[WebSocket] Client Key:", key);
422
-
423
206
  try {
424
- const acceptKey = this.generateAcceptKey(key);
425
- this.log("[WebSocket] Generated Accept Key:", acceptKey);
426
-
427
- const response = [
428
- "HTTP/1.1 101 Switching Protocols",
429
- "Upgrade: websocket",
430
- "Connection: Upgrade",
431
- `Sec-WebSocket-Accept: ${acceptKey}`,
432
- "\r\n",
433
- ].join("\r\n");
434
-
435
- this.log(
436
- "[WebSocket] Sending Handshake Response:",
437
- JSON.stringify(response),
438
- );
439
- managed.socket.write(response);
440
-
441
- // Assign cryptographically random ID and store
442
- managed.id = generateId();
443
- this.clients.set(managed.id, managed);
444
- this.emit("connection", managed.id);
207
+ const message = JSON.stringify(data);
208
+ this.clients.forEach((client, id) => {
209
+ if (id !== excludeId) {
210
+ client.ws.send(message).catch((error: Error) => {
211
+ this.log(`[WebSocket] Failed to broadcast to ${id}:`, error);
212
+ // Don't abort -- continue sending to remaining clients
213
+ });
214
+ }
215
+ });
445
216
  } catch (error) {
446
- console.error("[WebSocket] Handshake error:", error);
447
- managed.socket.destroy();
448
- }
449
- }
450
-
451
- private generateAcceptKey(key: string): string {
452
- const input = key + GUID;
453
- const hash = sha1(input);
454
- this.log(`[WebSocket] SHA1 Input: ${input}`);
455
- this.log(`[WebSocket] SHA1 Hash (hex): ${hash}`);
456
- return Buffer.from(hash, "hex").toString("base64");
457
- }
458
-
459
- private decodeFrame(buffer: Buffer): DecodedFrame | null {
460
- // Need at least 2 bytes for the header
461
- if (buffer.length < 2) return null;
462
-
463
- const firstByte = buffer[0];
464
- const opcode = firstByte & 0x0f;
465
-
466
- const secondByte = buffer[1];
467
- const isMasked = (secondByte & 0x80) !== 0;
468
- let payloadLength = secondByte & 0x7f;
469
- let headerLength = 2;
470
-
471
- if (payloadLength === 126) {
472
- if (buffer.length < 4) return null; // Need 2 more bytes for extended length
473
- payloadLength = buffer.readUInt16BE(2);
474
- headerLength = 4;
475
- } else if (payloadLength === 127) {
476
- if (buffer.length < 10) return null; // Need 8 more bytes for extended length
477
- // Read 64-bit length. For safety, only use the lower 32 bits.
478
- const highBits = buffer.readUInt32BE(2);
479
- if (highBits > 0) {
480
- throw new Error("Frame payload too large (exceeds 4 GB)");
481
- }
482
- payloadLength = buffer.readUInt32BE(6);
483
- headerLength = 10;
484
- }
485
-
486
- // Enforce max frame size to prevent memory exhaustion attacks
487
- if (payloadLength > this.maxFrameSize) {
488
- throw new Error(
489
- `Frame payload (${payloadLength} bytes) exceeds maximum allowed size (${this.maxFrameSize} bytes)`,
490
- );
491
- }
492
-
493
- const maskLength = isMasked ? 4 : 0;
494
- const totalFrameLength = headerLength + maskLength + payloadLength;
495
-
496
- // Check if we have the complete frame
497
- if (buffer.length < totalFrameLength) return null;
498
-
499
- let payload: Buffer;
500
- if (isMasked) {
501
- const mask = buffer.subarray(headerLength, headerLength + 4);
502
- const maskedPayload = buffer.subarray(
503
- headerLength + 4,
504
- headerLength + 4 + payloadLength,
505
- );
506
- payload = Buffer.alloc(payloadLength);
507
- for (let i = 0; i < payloadLength; i++) {
508
- payload[i] = maskedPayload[i] ^ mask[i % 4];
509
- }
510
- } else {
511
- payload = Buffer.from(
512
- buffer.subarray(headerLength, headerLength + payloadLength),
217
+ this.log(`[WebSocket] Failed to serialize broadcast message:`, error);
218
+ this.emit(
219
+ "error",
220
+ error instanceof Error ? error : new Error(String(error)),
513
221
  );
514
222
  }
515
-
516
- return { opcode, payload, bytesConsumed: totalFrameLength };
517
- }
518
-
519
- private encodeFrame(data: string): Buffer {
520
- // Server -> Client frames are NOT masked (text frame)
521
- return this.buildFrame(OPCODE.TEXT, Buffer.from(data));
522
- }
523
-
524
- private encodeControlFrame(opcode: number, payload: Buffer): Buffer {
525
- return this.buildFrame(opcode, payload);
526
223
  }
527
224
 
528
- private buildFrame(opcode: number, payload: Buffer): Buffer {
529
- let headerLength = 2;
530
-
531
- if (payload.length > 65535) {
532
- headerLength = 10; // 2 header + 8 length
533
- } else if (payload.length > 125) {
534
- headerLength = 4; // 2 header + 2 length
535
- }
536
-
537
- const frame = Buffer.alloc(headerLength + payload.length);
538
- frame[0] = 0x80 | opcode; // FIN bit set + opcode
539
-
540
- if (payload.length > 65535) {
541
- frame[1] = 127;
542
- // Write 64-bit integer (max safe integer in JS is 2^53, so high 32 bits are 0)
543
- frame.writeUInt32BE(0, 2);
544
- frame.writeUInt32BE(payload.length, 6);
545
- } else if (payload.length > 125) {
546
- frame[1] = 126;
547
- frame.writeUInt16BE(payload.length, 2);
548
- } else {
549
- frame[1] = payload.length;
550
- }
551
-
552
- payload.copy(frame, headerLength);
553
- return frame;
225
+ /** Returns the number of currently connected clients. */
226
+ public get clientCount(): number {
227
+ return this.clients.size;
554
228
  }
555
229
  }
@@ -1,27 +0,0 @@
1
- /**
2
- * Buffer management utilities for WebSocket per-client receive buffers.
3
- * Extracted into a standalone module so they can be tested without
4
- * react-native dependencies.
5
- */
6
- import { Buffer } from "buffer";
7
- import type { TcpSocketInstance } from "./declarations";
8
- export interface ManagedSocket {
9
- socket: TcpSocketInstance;
10
- id: string;
11
- isHandshakeComplete: boolean;
12
- buffer: Buffer;
13
- /** Number of valid bytes currently in `buffer` (may be less than buffer.length). */
14
- bufferLength: number;
15
- lastPong: number;
16
- }
17
- /**
18
- * Append data to a managed socket's buffer, growing capacity geometrically
19
- * to avoid re-allocation on every TCP data event.
20
- */
21
- export declare function appendToBuffer(managed: ManagedSocket, data: Buffer): void;
22
- /**
23
- * Compact the buffer by discarding consumed bytes from the front.
24
- * If all data has been consumed, reset the length to 0 without re-allocating.
25
- */
26
- export declare function compactBuffer(managed: ManagedSocket, consumed: number): void;
27
- //# sourceMappingURL=buffer-utils.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"buffer-utils.d.ts","sourceRoot":"","sources":["../src/buffer-utils.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AAChC,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAGxD,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,iBAAiB,CAAC;IAC1B,EAAE,EAAE,MAAM,CAAC;IACX,mBAAmB,EAAE,OAAO,CAAC;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,oFAAoF;IACpF,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAAC,OAAO,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAazE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAS5E"}
@@ -1,53 +0,0 @@
1
- /**
2
- * Buffer management utilities for WebSocket per-client receive buffers.
3
- * Extracted into a standalone module so they can be tested without
4
- * react-native dependencies.
5
- */
6
-
7
- import { Buffer } from "buffer";
8
- import type { TcpSocketInstance } from "./declarations";
9
-
10
- // Internal type for a TCP socket with our added management properties.
11
- export interface ManagedSocket {
12
- socket: TcpSocketInstance;
13
- id: string;
14
- isHandshakeComplete: boolean;
15
- buffer: Buffer;
16
- /** Number of valid bytes currently in `buffer` (may be less than buffer.length). */
17
- bufferLength: number;
18
- lastPong: number;
19
- }
20
-
21
- /**
22
- * Append data to a managed socket's buffer, growing capacity geometrically
23
- * to avoid re-allocation on every TCP data event.
24
- */
25
- export function appendToBuffer(managed: ManagedSocket, data: Buffer): void {
26
- const needed = managed.bufferLength + data.length;
27
-
28
- if (needed > managed.buffer.length) {
29
- // Grow by at least 2x or to fit the new data, whichever is larger
30
- const newCapacity = Math.max(managed.buffer.length * 2, needed);
31
- const grown = Buffer.alloc(newCapacity);
32
- managed.buffer.copy(grown, 0, 0, managed.bufferLength);
33
- managed.buffer = grown;
34
- }
35
-
36
- data.copy(managed.buffer, managed.bufferLength);
37
- managed.bufferLength = needed;
38
- }
39
-
40
- /**
41
- * Compact the buffer by discarding consumed bytes from the front.
42
- * If all data has been consumed, reset the length to 0 without re-allocating.
43
- */
44
- export function compactBuffer(managed: ManagedSocket, consumed: number): void {
45
- const remaining = managed.bufferLength - consumed;
46
- if (remaining <= 0) {
47
- managed.bufferLength = 0;
48
- return;
49
- }
50
- // Shift remaining bytes to the front of the existing buffer
51
- managed.buffer.copy(managed.buffer, 0, consumed, managed.bufferLength);
52
- managed.bufferLength = remaining;
53
- }
@@ -1,25 +0,0 @@
1
- /**
2
- * Minimal type definition for the raw TCP socket provided by react-native-tcp-socket.
3
- * Covers only the API surface used by GameWebSocketServer.
4
- */
5
-
6
- import type { Buffer } from "buffer";
7
-
8
- export interface TcpSocketInstance {
9
- write(data: string | Buffer): void;
10
- destroy(): void;
11
- on(event: "data", callback: (data: Buffer | string) => void): this;
12
- on(event: "error", callback: (error: Error) => void): this;
13
- on(event: "close", callback: (hadError: boolean) => void): this;
14
- address():
15
- | { address: string; family: string; port: number }
16
- | Record<string, never>;
17
- readonly destroyed: boolean;
18
- }
19
-
20
- declare module "react-native-nitro-http-server" {
21
- export class StaticServer {
22
- start(port: number, path: string, host?: string): Promise<void>;
23
- stop(): void;
24
- }
25
- }