@couch-kit/host 0.4.0 → 0.5.1

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,6 @@
1
1
  {
2
2
  "name": "@couch-kit/host",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "React Native host for local multiplayer party games on Android TV — WebSocket server, state management, and static file serving",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,6 +22,14 @@
22
22
  "main": "src/index.tsx",
23
23
  "react-native": "src/index.tsx",
24
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,
25
33
  "files": [
26
34
  "src",
27
35
  "lib",
@@ -43,12 +51,12 @@
43
51
  "clean": "del-cli lib"
44
52
  },
45
53
  "dependencies": {
46
- "@couch-kit/core": "0.3.0",
54
+ "@couch-kit/core": "0.3.1",
55
+ "buffer": "^6.0.3",
47
56
  "js-sha1": "^0.7.0",
48
57
  "react-native-fs": "^2.20.0",
49
58
  "react-native-network-info": "^5.2.1",
50
59
  "react-native-nitro-http-server": "^1.5.4",
51
- "react-native-nitro-modules": "^0.33.2",
52
60
  "react-native-tcp-socket": "^6.0.6"
53
61
  },
54
62
  "devDependencies": {
@@ -61,6 +69,7 @@
61
69
  },
62
70
  "peerDependencies": {
63
71
  "react": "*",
64
- "react-native": "*"
72
+ "react-native": "*",
73
+ "react-native-nitro-modules": ">=0.33.0"
65
74
  }
66
75
  }
@@ -16,12 +16,12 @@
16
16
  * ```
17
17
  */
18
18
 
19
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ /* eslint-disable @typescript-eslint/no-explicit-any */
20
20
  export class EventEmitter<
21
21
  EventMap extends Record<string, any[]> = Record<string, any[]>,
22
22
  > {
23
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
23
  private listeners: Map<string, Array<(...args: any[]) => void>> = new Map();
24
+ /* eslint-enable @typescript-eslint/no-explicit-any */
25
25
 
26
26
  on<K extends string & keyof EventMap>(
27
27
  event: K,
package/src/provider.tsx CHANGED
@@ -5,6 +5,7 @@ import React, {
5
5
  useMemo,
6
6
  useReducer,
7
7
  useRef,
8
+ useCallback,
8
9
  } from "react";
9
10
  import { GameWebSocketServer } from "./websocket";
10
11
  import { useStaticServer } from "./server";
@@ -13,6 +14,7 @@ import {
13
14
  InternalActionTypes,
14
15
  DEFAULT_HTTP_PORT,
15
16
  DEFAULT_WS_PORT_OFFSET,
17
+ createGameReducer,
16
18
  type IGameState,
17
19
  type IAction,
18
20
  type InternalAction,
@@ -21,7 +23,7 @@ import {
21
23
 
22
24
  export interface GameHostConfig<S extends IGameState, A extends IAction> {
23
25
  initialState: S;
24
- reducer: (state: S, action: A | InternalAction<S>) => S;
26
+ reducer: (state: S, action: A) => S;
25
27
  port?: number; // Static server port (default 8080)
26
28
  wsPort?: number; // WebSocket port (default: HTTP port + 2, i.e. 8082)
27
29
  devMode?: boolean;
@@ -38,7 +40,7 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
38
40
 
39
41
  interface GameHostContextValue<S extends IGameState, A extends IAction> {
40
42
  state: S;
41
- dispatch: (action: A | InternalAction<S>) => void;
43
+ dispatch: (action: A) => void;
42
44
  serverUrl: string | null;
43
45
  serverError: Error | null;
44
46
  }
@@ -92,7 +94,12 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
92
94
  children: React.ReactNode;
93
95
  config: GameHostConfig<S, A>;
94
96
  }) {
95
- const [state, dispatch] = useReducer(config.reducer, config.initialState);
97
+ // Wrap the user's reducer with createGameReducer to handle internal actions
98
+ // (HYDRATE, PLAYER_JOINED, PLAYER_LEFT) automatically.
99
+ const [state, dispatch] = useReducer(
100
+ createGameReducer(config.reducer),
101
+ config.initialState,
102
+ );
96
103
 
97
104
  // Keep a ref to state so we can access it inside callbacks/effects that don't depend on it
98
105
  const stateRef = useRef(state);
@@ -262,22 +269,40 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
262
269
  return () => {
263
270
  server.stop();
264
271
  };
265
- // eslint-disable-next-line react-hooks/exhaustive-deps
266
272
  }, []); // Run once on mount
267
273
 
268
- // 3. Broadcast State Updates
269
- // Whenever React state changes, send it to all clients that have been welcomed
270
- useEffect(() => {
274
+ // 3. Throttled State Broadcasts (~30fps)
275
+ // Batches rapid state changes so at most one broadcast is sent per ~33ms frame,
276
+ // reducing serialization overhead and network traffic for fast-updating games.
277
+ const broadcastPending = useRef(false);
278
+ const broadcastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
279
+
280
+ const broadcastState = useCallback(() => {
271
281
  if (wsServer.current) {
272
282
  wsServer.current.broadcast({
273
283
  type: MessageTypes.STATE_UPDATE,
274
284
  payload: {
275
- newState: state,
285
+ newState: stateRef.current,
276
286
  timestamp: Date.now(),
277
287
  },
278
288
  });
279
289
  }
280
- }, [state]);
290
+ broadcastPending.current = false;
291
+ }, []);
292
+
293
+ useEffect(() => {
294
+ if (!broadcastPending.current) {
295
+ broadcastPending.current = true;
296
+ broadcastTimer.current = setTimeout(broadcastState, 33); // ~30fps
297
+ }
298
+
299
+ return () => {
300
+ if (broadcastTimer.current) {
301
+ clearTimeout(broadcastTimer.current);
302
+ broadcastTimer.current = null;
303
+ }
304
+ };
305
+ }, [state, broadcastState]);
281
306
 
282
307
  // Memoize context value to prevent unnecessary re-renders of consumers
283
308
  // that only use stable references like dispatch
package/src/websocket.ts CHANGED
@@ -50,6 +50,43 @@ const OPCODE = {
50
50
  // Simple WebSocket Frame Parser/Builder
51
51
  const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
52
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
+
53
90
  interface DecodedFrame {
54
91
  opcode: number;
55
92
  payload: Buffer;
@@ -64,6 +101,8 @@ interface ManagedSocket {
64
101
  id: string;
65
102
  isHandshakeComplete: boolean;
66
103
  buffer: Buffer;
104
+ /** Number of valid bytes currently in `buffer` (may be less than buffer.length). */
105
+ bufferLength: number;
67
106
  lastPong: number;
68
107
  }
69
108
 
@@ -105,7 +144,8 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
105
144
  socket: rawSocket,
106
145
  id: "",
107
146
  isHandshakeComplete: false,
108
- buffer: Buffer.alloc(0),
147
+ buffer: Buffer.alloc(INITIAL_BUFFER_CAPACITY),
148
+ bufferLength: 0,
109
149
  lastPong: Date.now(),
110
150
  };
111
151
 
@@ -113,24 +153,26 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
113
153
  this.log(
114
154
  `[WebSocket] Received data chunk: ${typeof data === "string" ? data.length : data.length} bytes`,
115
155
  );
116
- // Concatenate new data
117
- managed.buffer = Buffer.concat([
118
- managed.buffer,
119
- typeof data === "string" ? Buffer.from(data) : data,
120
- ]);
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);
121
159
 
122
160
  // Handshake not yet performed?
123
161
  if (!managed.isHandshakeComplete) {
124
- const header = managed.buffer.toString("utf8");
162
+ const header = managed.buffer.toString(
163
+ "utf8",
164
+ 0,
165
+ managed.bufferLength,
166
+ );
125
167
  const endOfHeader = header.indexOf("\r\n\r\n");
126
168
  if (endOfHeader !== -1) {
127
169
  this.handleHandshake(managed, header);
128
- // Retain any bytes after the handshake (could be the first WS frame)
170
+ // Compact buffer past the handshake header
129
171
  const headerByteLength = Buffer.byteLength(
130
172
  header.substring(0, endOfHeader + 4),
131
173
  "utf8",
132
174
  );
133
- managed.buffer = managed.buffer.slice(headerByteLength);
175
+ compactBuffer(managed, headerByteLength);
134
176
  managed.isHandshakeComplete = true;
135
177
  // Fall through to process any remaining frames below
136
178
  } else {
@@ -139,9 +181,7 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
139
181
  }
140
182
 
141
183
  // Process all complete frames in the buffer
142
- this.processFrames(managed, (remaining) => {
143
- managed.buffer = remaining;
144
- });
184
+ this.processFrames(managed);
145
185
  });
146
186
 
147
187
  rawSocket.on("error", (error: Error) => {
@@ -204,16 +244,15 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
204
244
  }, this.keepaliveInterval);
205
245
  }
206
246
 
207
- private processFrames(
208
- managed: ManagedSocket,
209
- setBuffer: (b: Buffer) => void,
210
- ) {
211
- let { buffer } = managed;
247
+ private processFrames(managed: ManagedSocket) {
248
+ let offset = 0;
212
249
 
213
- while (buffer.length > 0) {
250
+ while (offset < managed.bufferLength) {
251
+ // Create a view of the unconsumed portion for decoding
252
+ const view = managed.buffer.subarray(offset, managed.bufferLength);
214
253
  let frame: DecodedFrame | null;
215
254
  try {
216
- frame = this.decodeFrame(buffer);
255
+ frame = this.decodeFrame(view);
217
256
  } catch (error) {
218
257
  // Frame too large or malformed -- disconnect the client
219
258
  this.log(`[WebSocket] Frame error from ${managed.id}:`, error);
@@ -226,12 +265,12 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
226
265
  }
227
266
 
228
267
  if (!frame) {
229
- // Incomplete frame -- keep buffer, wait for more data
268
+ // Incomplete frame -- keep remaining bytes, wait for more data
230
269
  break;
231
270
  }
232
271
 
233
- // Advance buffer past the consumed frame
234
- buffer = buffer.slice(frame.bytesConsumed);
272
+ // Advance past the consumed frame
273
+ offset += frame.bytesConsumed;
235
274
 
236
275
  // Handle frame by opcode
237
276
  switch (frame.opcode) {
@@ -302,7 +341,8 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
302
341
  if (managed.socket.destroyed) break;
303
342
  }
304
343
 
305
- setBuffer(buffer);
344
+ // Compact buffer: shift unconsumed bytes to the front
345
+ compactBuffer(managed, offset);
306
346
  }
307
347
 
308
348
  /**
@@ -486,8 +526,8 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
486
526
 
487
527
  let payload: Buffer;
488
528
  if (isMasked) {
489
- const mask = buffer.slice(headerLength, headerLength + 4);
490
- const maskedPayload = buffer.slice(
529
+ const mask = buffer.subarray(headerLength, headerLength + 4);
530
+ const maskedPayload = buffer.subarray(
491
531
  headerLength + 4,
492
532
  headerLength + 4 + payloadLength,
493
533
  );
@@ -497,7 +537,7 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
497
537
  }
498
538
  } else {
499
539
  payload = Buffer.from(
500
- buffer.slice(headerLength, headerLength + payloadLength),
540
+ buffer.subarray(headerLength, headerLength + payloadLength),
501
541
  );
502
542
  }
503
543