@couch-kit/host 0.5.1 → 0.6.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/README.md CHANGED
@@ -50,14 +50,20 @@ Returns:
50
50
  - `serverUrl`: HTTP URL phones should open (or `devServerUrl` in dev mode)
51
51
  - `serverError`: static server error (if startup fails)
52
52
 
53
- ## System Actions (Important)
53
+ ## System Actions
54
54
 
55
- The host will dispatch a few **system action types** into your reducer. Treat these as reserved:
55
+ The host automatically dispatches internal system actions (`__PLAYER_JOINED__`, `__PLAYER_LEFT__`, `__HYDRATE__`) into `createGameReducer`, which handles them for you. **You do not need to handle these in your reducer.**
56
56
 
57
- - `PLAYER_JOINED`: payload `{ id: string, name: string, avatar?: string, secret?: string }`
58
- - `PLAYER_LEFT`: payload `{ playerId: string }`
57
+ Player tracking (`state.players`) is managed automatically:
59
58
 
60
- If you want to track players in `state.players`, handle these action types in your reducer. The `secret` field can be used to identify returning players.
59
+ - When a player joins, they are added to `state.players` with `connected: true`.
60
+ - When a player disconnects, they are marked as `connected: false`.
61
+
62
+ To react to player events outside of state (e.g., logging, analytics), use the callback config options:
63
+
64
+ - `onPlayerJoined?: (playerId: string, name: string) => void` -- called when a player successfully joins.
65
+ - `onPlayerLeft?: (playerId: string) => void` -- called when a player disconnects.
66
+ - `onError?: (error: Error) => void` -- called when a server error occurs.
61
67
 
62
68
  ### 1. Configure the Provider
63
69
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/host",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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": {
@@ -45,13 +45,13 @@
45
45
  "!**/__mocks__"
46
46
  ],
47
47
  "scripts": {
48
- "test": "jest --passWithNoTests",
48
+ "test": "bun test",
49
49
  "typecheck": "tsc --noEmit",
50
50
  "lint": "eslint \"**/*.{js,ts,tsx}\"",
51
51
  "clean": "del-cli lib"
52
52
  },
53
53
  "dependencies": {
54
- "@couch-kit/core": "0.3.1",
54
+ "@couch-kit/core": "0.3.3",
55
55
  "buffer": "^6.0.3",
56
56
  "js-sha1": "^0.7.0",
57
57
  "react-native-fs": "^2.20.0",
@@ -60,16 +60,16 @@
60
60
  "react-native-tcp-socket": "^6.0.6"
61
61
  },
62
62
  "devDependencies": {
63
- "@types/react": "~17.0.21",
64
- "@types/react-native": "0.70.0",
63
+ "@types/react": "^18.2.0",
64
+ "@types/react-native": "^0.72.0",
65
65
  "react": "18.2.0",
66
66
  "react-native": "0.72.6",
67
67
  "del-cli": "^5.1.0",
68
68
  "jest": "^29.7.0"
69
69
  },
70
70
  "peerDependencies": {
71
- "react": "*",
72
- "react-native": "*",
71
+ "react": ">=18.2.0",
72
+ "react-native": ">=0.72.0",
73
73
  "react-native-nitro-modules": ">=0.33.0"
74
74
  }
75
75
  }
@@ -0,0 +1,53 @@
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,3 +1,19 @@
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
+ export interface TcpSocketInstance {
6
+ write(data: string | Buffer): void;
7
+ destroy(): void;
8
+ on(event: "data", callback: (data: Buffer | string) => void): this;
9
+ on(event: "error", callback: (error: Error) => void): this;
10
+ on(event: "close", callback: (hadError: boolean) => void): this;
11
+ address():
12
+ | { address: string; family: string; port: number }
13
+ | Record<string, never>;
14
+ readonly destroyed: boolean;
15
+ }
16
+
1
17
  declare module "react-native-nitro-http-server" {
2
18
  export class StaticServer {
3
19
  start(port: number, path: string, host?: string): Promise<void>;
@@ -75,7 +75,10 @@ export class EventEmitter<
75
75
  try {
76
76
  listener(...args);
77
77
  } catch (error) {
78
- console.error(`Error in event listener for "${event}":`, error);
78
+ console.error(
79
+ `[EventEmitter] Error in listener for "${String(event)}":`,
80
+ error,
81
+ );
79
82
  }
80
83
  }
81
84
  return true;
package/src/provider.tsx CHANGED
@@ -87,6 +87,22 @@ function isValidClientMessage(msg: unknown): msg is ClientMessage {
87
87
  }
88
88
  }
89
89
 
90
+ /**
91
+ * React context provider that turns a React Native TV app into a local game server.
92
+ *
93
+ * Starts a static file server (for the web controller) and a WebSocket game server
94
+ * (for real-time state sync). Manages the canonical game state using the provided
95
+ * reducer and broadcasts state updates to all connected clients.
96
+ *
97
+ * @param config - Host configuration including reducer, initial state, ports, and callbacks.
98
+ *
99
+ * @example
100
+ * ```tsx
101
+ * <GameHostProvider config={{ reducer: gameReducer, initialState }}>
102
+ * <GameScreen />
103
+ * </GameHostProvider>
104
+ * ```
105
+ */
90
106
  export function GameHostProvider<S extends IGameState, A extends IAction>({
91
107
  children,
92
108
  config,
@@ -318,6 +334,16 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
318
334
  );
319
335
  }
320
336
 
337
+ /**
338
+ * React hook to access the game host context.
339
+ *
340
+ * Must be used within a `<GameHostProvider>`. Returns the canonical game state,
341
+ * a dispatch function for actions, the server URL (for QR codes), and any
342
+ * server startup errors.
343
+ *
344
+ * @returns An object with `state`, `dispatch`, `serverUrl`, and `serverError`.
345
+ * @throws If used outside of a `<GameHostProvider>`.
346
+ */
321
347
  export function useGameHost<S extends IGameState, A extends IAction>() {
322
348
  const context = useContext(GameHostContext);
323
349
  if (!context) {
package/src/server.ts CHANGED
@@ -11,6 +11,16 @@ export interface CouchKitHostConfig {
11
11
  staticDir?: string; // Override the default www directory path (required on Android)
12
12
  }
13
13
 
14
+ /**
15
+ * React hook that manages a static HTTP file server for serving the web controller.
16
+ *
17
+ * In production mode, starts a `StaticServer` bound to `0.0.0.0` on the configured port,
18
+ * serving files from `staticDir` (or `${RNFS.MainBundlePath}/www` by default).
19
+ * In dev mode, skips the server and returns `devServerUrl` directly.
20
+ *
21
+ * @param config - Server configuration including port, dev mode, and static directory.
22
+ * @returns An object with `url` (the server URL or null), `error`, and `loading`.
23
+ */
14
24
  export const useStaticServer = (config: CouchKitHostConfig) => {
15
25
  const [url, setUrl] = useState<string | null>(null);
16
26
  const [error, setError] = useState<Error | null>(null);
package/src/websocket.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import TcpSocket from "react-native-tcp-socket";
10
+ import type { TcpSocketInstance } from "./declarations";
10
11
  import { EventEmitter } from "./event-emitter";
11
12
  import { Buffer } from "buffer";
12
13
  import { sha1 } from "js-sha1";
@@ -16,6 +17,8 @@ import {
16
17
  KEEPALIVE_INTERVAL,
17
18
  KEEPALIVE_TIMEOUT,
18
19
  } from "@couch-kit/core";
20
+ import { appendToBuffer, compactBuffer } from "./buffer-utils";
21
+ import type { ManagedSocket } from "./buffer-utils";
19
22
 
20
23
  export interface WebSocketConfig {
21
24
  port: number;
@@ -53,59 +56,12 @@ const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
53
56
  /** Initial capacity for per-client receive buffers. */
54
57
  const INITIAL_BUFFER_CAPACITY = 4096;
55
58
 
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
-
90
59
  interface DecodedFrame {
91
60
  opcode: number;
92
61
  payload: Buffer;
93
62
  bytesConsumed: number;
94
63
  }
95
64
 
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 {
99
- // eslint-disable-next-line @typescript-eslint/no-explicit-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
65
  export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
110
66
  private server: ReturnType<typeof TcpSocket.createServer> | null = null;
111
67
  private clients: Map<string, ManagedSocket> = new Map();
@@ -134,11 +90,11 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
134
90
  public start() {
135
91
  this.log(`[WebSocket] Starting server on port ${this.port}...`);
136
92
 
137
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
138
- this.server = TcpSocket.createServer((rawSocket: any) => {
139
- this.log(
140
- `[WebSocket] New connection from ${rawSocket.address?.()?.address}`,
141
- );
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}`);
142
98
 
143
99
  const managed: ManagedSocket = {
144
100
  socket: rawSocket,
@@ -227,8 +183,11 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
227
183
  this.log(`[WebSocket] Keepalive timeout for ${id}, disconnecting`);
228
184
  try {
229
185
  managed.socket.destroy();
230
- } catch {
231
- // Already destroyed
186
+ } catch (error) {
187
+ this.log(
188
+ "[WebSocket] Socket already destroyed during keepalive cleanup:",
189
+ error,
190
+ );
232
191
  }
233
192
  this.clients.delete(id);
234
193
  this.emit("disconnect", id);
@@ -237,8 +196,8 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
237
196
 
238
197
  try {
239
198
  managed.socket.write(pingFrame);
240
- } catch {
241
- // Socket already closing
199
+ } catch (error) {
200
+ this.log("[WebSocket] Failed to send keepalive ping:", error);
242
201
  }
243
202
  }
244
203
  }, this.keepaliveInterval);
@@ -258,8 +217,11 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
258
217
  this.log(`[WebSocket] Frame error from ${managed.id}:`, error);
259
218
  try {
260
219
  managed.socket.destroy();
261
- } catch {
262
- // Already destroyed
220
+ } catch (destroyError) {
221
+ this.log(
222
+ "[WebSocket] Socket already destroyed after frame error:",
223
+ destroyError,
224
+ );
263
225
  }
264
226
  return;
265
227
  }
@@ -278,10 +240,11 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
278
240
  try {
279
241
  const message = JSON.parse(frame.payload.toString("utf8"));
280
242
  this.emit("message", managed.id, message);
281
- } catch {
243
+ } catch (error) {
282
244
  // Corrupt JSON in a complete frame -- discard this frame, continue processing
283
245
  this.log(
284
- `[WebSocket] Invalid JSON from ${managed.id}, discarding frame`,
246
+ `[WebSocket] Invalid JSON from ${managed.id}, discarding frame:`,
247
+ error,
285
248
  );
286
249
  }
287
250
  break;
@@ -295,8 +258,8 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
295
258
  closeFrame[1] = 0x00; // No payload
296
259
  try {
297
260
  managed.socket.write(closeFrame);
298
- } catch {
299
- // Socket may already be closing
261
+ } catch (error) {
262
+ this.log("[WebSocket] Failed to send close frame:", error);
300
263
  }
301
264
  managed.socket.destroy();
302
265
  break;
@@ -308,8 +271,8 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
308
271
  const pongFrame = this.encodeControlFrame(OPCODE.PONG, frame.payload);
309
272
  try {
310
273
  managed.socket.write(pongFrame);
311
- } catch {
312
- // Socket may be closing
274
+ } catch (error) {
275
+ this.log("[WebSocket] Failed to send pong:", error);
313
276
  }
314
277
  break;
315
278
  }
@@ -365,13 +328,19 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
365
328
  this.clients.forEach((managed) => {
366
329
  try {
367
330
  managed.socket.write(closeFrame);
368
- } catch {
369
- // Socket may already be closing
331
+ } catch (error) {
332
+ this.log(
333
+ "[WebSocket] Failed to send close frame during shutdown:",
334
+ error,
335
+ );
370
336
  }
371
337
  try {
372
338
  managed.socket.destroy();
373
- } catch {
374
- // Already destroyed
339
+ } catch (error) {
340
+ this.log(
341
+ "[WebSocket] Socket already destroyed during shutdown:",
342
+ error,
343
+ );
375
344
  }
376
345
  });
377
346
 
@@ -425,7 +394,6 @@ export class GameWebSocketServer extends EventEmitter<WebSocketServerEvents> {
425
394
 
426
395
  // --- Private Helpers ---
427
396
 
428
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
429
397
  private handleHandshake(managed: ManagedSocket, header: string) {
430
398
  this.log("[WebSocket] Handshake request header:", JSON.stringify(header));
431
399
  const keyMatch = header.match(/Sec-WebSocket-Key: (.+)/);