@couch-kit/host 1.2.7 → 1.3.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/README.md CHANGED
@@ -26,10 +26,10 @@ Then install the required peer dependencies:
26
26
 
27
27
  ```bash
28
28
  npx expo install expo-file-system expo-network
29
- bun add react-native-tcp-socket
29
+ bun add react-native-tcp-socket react-native-nitro-modules
30
30
  ```
31
31
 
32
- > **Note:** This library requires Expo modules (`expo-file-system`, `expo-network`) and `react-native-tcp-socket` as peer dependencies. These must be installed in your consumer app. React Native's autolinking will handle native setup automatically.
32
+ > **Note:** This library requires Expo modules (`expo-file-system`, `expo-network`), `react-native-tcp-socket`, and `react-native-nitro-modules` as peer dependencies. These must be installed in your consumer app. React Native's autolinking will handle native setup automatically.
33
33
 
34
34
  ## Compatibility
35
35
 
@@ -45,8 +45,6 @@ bun add react-native-tcp-socket
45
45
 
46
46
  > **New Architecture:** This package supports React Native's New Architecture (Fabric/TurboModules) via React Native 0.83+.
47
47
 
48
- ## Usage
49
-
50
48
  ## API
51
49
 
52
50
  ### `<GameHostProvider config={...}>`
@@ -71,14 +69,48 @@ Returns:
71
69
  - `serverUrl`: HTTP URL phones should open (or `devServerUrl` in dev mode)
72
70
  - `serverError`: static server error (if startup fails)
73
71
 
72
+ ### `useExtractAssets(manifest)`
73
+
74
+ Extracts bundled web assets from the APK to a writable directory so the native HTTP server can serve them.
75
+
76
+ On Android, assets live inside the APK and cannot be served directly. This hook copies each file listed in the manifest from the APK to the device filesystem. On iOS, extraction is skipped since assets are accessible from the bundle directory.
77
+
78
+ ```tsx
79
+ import { useExtractAssets } from "@couch-kit/host";
80
+ import manifest from "./www/manifest.json";
81
+
82
+ function App() {
83
+ const { staticDir, loading, error } = useExtractAssets(manifest);
84
+
85
+ if (loading) return <Text>Extracting assets...</Text>;
86
+ if (error) return <Text>Error: {error}</Text>;
87
+
88
+ return (
89
+ <GameHostProvider config={{ staticDir, reducer, initialState }}>
90
+ ...
91
+ </GameHostProvider>
92
+ );
93
+ }
94
+ ```
95
+
96
+ | Property | Type | Description |
97
+ | ----------- | --------------------- | ------------------------------------------------- |
98
+ | `staticDir` | `string \| undefined` | Filesystem path to extracted assets, or undefined |
99
+ | `loading` | `boolean` | Whether extraction is in progress |
100
+ | `error` | `string \| null` | Error message if extraction failed |
101
+
74
102
  ## System Actions
75
103
 
76
- 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.**
104
+ The host automatically dispatches internal system actions (`__PLAYER_JOINED__`, `__PLAYER_LEFT__`, `__PLAYER_RECONNECTED__`, `__PLAYER_REMOVED__`, `__HYDRATE__`) into `createGameReducer`, which handles them for you. **You do not need to handle these in your reducer.**
77
105
 
78
106
  Player tracking (`state.players`) is managed automatically:
79
107
 
80
108
  - When a player joins, they are added to `state.players` with `connected: true`.
81
109
  - When a player disconnects, they are marked as `connected: false`.
110
+ - If a player reconnects from the same device/browser, they are automatically reassigned their previous player data via `__PLAYER_RECONNECTED__` (sets `connected: true`, preserves hand, score, etc.).
111
+ - If a disconnected player does not reconnect within the timeout window (default: 5 minutes), they are permanently removed from `state.players` via `__PLAYER_REMOVED__`.
112
+
113
+ Game logic that iterates over `state.players` should account for players being removed after the timeout.
82
114
 
83
115
  To react to player events outside of state (e.g., logging, analytics), use the callback config options:
84
116
 
@@ -157,6 +189,8 @@ To iterate on your web controller without rebuilding the Android app constantly:
157
189
  ```tsx
158
190
  <GameHostProvider
159
191
  config={{
192
+ reducer: gameReducer,
193
+ initialState,
160
194
  devMode: true,
161
195
  devServerUrl: 'http://192.168.1.50:5173' // Your laptop's IP
162
196
  }}
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAML,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,qBA4OA;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,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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@couch-kit/host",
3
- "version": "1.2.7",
3
+ "version": "1.3.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": {
@@ -51,7 +51,7 @@
51
51
  "prepublishOnly": "bun run build"
52
52
  },
53
53
  "dependencies": {
54
- "@couch-kit/core": "0.4.0",
54
+ "@couch-kit/core": "0.5.0",
55
55
  "buffer": "^6.0.3",
56
56
  "js-sha1": "^0.7.0",
57
57
  "react-native-nitro-http-server": "^1.5.4"
package/src/assets.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import * as FileSystem from "expo-file-system";
3
- import { Paths, Directory } from "expo-file-system/next";
3
+ import { Paths, Directory } from "expo-file-system";
4
4
  import { Platform } from "react-native";
5
5
  import { toErrorMessage } from "@couch-kit/core";
6
6
 
package/src/provider.tsx CHANGED
@@ -15,6 +15,8 @@ import {
15
15
  DEFAULT_HTTP_PORT,
16
16
  DEFAULT_WS_PORT_OFFSET,
17
17
  createGameReducer,
18
+ derivePlayerId,
19
+ isValidSecret,
18
20
  type IGameState,
19
21
  type IAction,
20
22
  type InternalAction,
@@ -130,12 +132,12 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
130
132
  if (!wsServer.current) return;
131
133
 
132
134
  const server = wsServer.current;
133
- for (const [socketId] of pendingWelcome.current) {
135
+ for (const [socketId, playerId] of pendingWelcome.current) {
134
136
  welcomedClients.current.add(socketId);
135
137
  server.send(socketId, {
136
138
  type: MessageTypes.WELCOME,
137
139
  payload: {
138
- playerId: socketId,
140
+ playerId,
139
141
  state,
140
142
  serverTime: Date.now(),
141
143
  },
@@ -162,9 +164,17 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
162
164
  // 2. Start WebSocket Server (Convention: HTTP port + 2, avoids Metro on 8081)
163
165
  const wsServer = useRef<GameWebSocketServer | null>(null);
164
166
 
165
- // Track active sessions: secret -> playerId
167
+ // Track active sessions: secret -> socketId
166
168
  const sessions = useRef<Map<string, string>>(new Map());
167
169
 
170
+ // Reverse lookup: socketId -> secret (for disconnect resolution)
171
+ const reverseMap = useRef<Map<string, string>>(new Map());
172
+
173
+ // Stale player cleanup timers: playerId -> timer
174
+ const cleanupTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(
175
+ new Map(),
176
+ );
177
+
168
178
  // Track socket IDs that have received their WELCOME message
169
179
  const welcomedClients = useRef<Set<string>>(new Set());
170
180
 
@@ -212,22 +222,51 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
212
222
  case MessageTypes.JOIN: {
213
223
  const { secret, ...payload } = message.payload;
214
224
 
215
- if (secret) {
216
- // Update the session map with the new socket ID for this secret
217
- sessions.current.set(secret, socketId);
225
+ // Validate secret format
226
+ if (!secret || !isValidSecret(secret)) {
227
+ server.send(socketId, {
228
+ type: MessageTypes.ERROR,
229
+ payload: {
230
+ code: "INVALID_SECRET",
231
+ message: "Invalid or missing session secret",
232
+ },
233
+ });
234
+ return;
218
235
  }
219
236
 
220
- // Dispatch the internal PLAYER_JOINED action
221
- dispatch({
222
- type: InternalActionTypes.PLAYER_JOINED,
223
- payload: { id: socketId, ...payload },
224
- } as InternalAction<S>);
237
+ const playerId = derivePlayerId(secret);
225
238
 
226
- // Queue WELCOME to be sent after React re-renders and stateRef is updated.
227
- // A useEffect watches for pending welcomes and sends them with fresh state.
228
- pendingWelcome.current.set(socketId, socketId);
239
+ // Update session maps
240
+ sessions.current.set(secret, socketId);
241
+ reverseMap.current.set(socketId, secret);
229
242
 
230
- configRef.current.onPlayerJoined?.(socketId, payload.name);
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);
231
270
  break;
232
271
  }
233
272
 
@@ -238,7 +277,9 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
238
277
  if (
239
278
  actionPayload.type === InternalActionTypes.HYDRATE ||
240
279
  actionPayload.type === InternalActionTypes.PLAYER_JOINED ||
241
- actionPayload.type === InternalActionTypes.PLAYER_LEFT
280
+ actionPayload.type === InternalActionTypes.PLAYER_LEFT ||
281
+ actionPayload.type === InternalActionTypes.PLAYER_RECONNECTED ||
282
+ actionPayload.type === InternalActionTypes.PLAYER_REMOVED
242
283
  ) {
243
284
  if (configRef.current.debug)
244
285
  console.warn(
@@ -255,7 +296,12 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
255
296
  });
256
297
  return;
257
298
  }
258
- dispatch(actionPayload);
299
+ // Resolve playerId from socketId
300
+ const actionSecret = reverseMap.current.get(socketId);
301
+ const resolvedPlayerId = actionSecret
302
+ ? derivePlayerId(actionSecret)
303
+ : undefined;
304
+ dispatch({ ...actionPayload, playerId: resolvedPlayerId });
259
305
  break;
260
306
  }
261
307
 
@@ -278,15 +324,41 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
278
324
 
279
325
  welcomedClients.current.delete(socketId);
280
326
 
281
- // We do NOT remove the session from the map here,
282
- // allowing them to reconnect later with the same secret.
327
+ // Resolve socketId -> secret -> playerId
328
+ const secret = reverseMap.current.get(socketId);
329
+ reverseMap.current.delete(socketId);
330
+
331
+ if (!secret) return; // Unknown socket, nothing to do
332
+
333
+ const playerId = derivePlayerId(secret);
334
+
335
+ // RACE GUARD: Only mark as left if this socket is still the active one for this secret
336
+ if (sessions.current.get(secret) !== socketId) {
337
+ // Player already reconnected on a newer socket — skip
338
+ return;
339
+ }
283
340
 
341
+ // Mark disconnected (don't remove from sessions — allow reconnect)
284
342
  dispatch({
285
343
  type: InternalActionTypes.PLAYER_LEFT,
286
- payload: { playerId: socketId },
344
+ payload: { playerId },
287
345
  } as InternalAction<S>);
288
346
 
289
- configRef.current.onPlayerLeft?.(socketId);
347
+ configRef.current.onPlayerLeft?.(playerId);
348
+
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
+ );
361
+ cleanupTimers.current.set(playerId, timer);
290
362
  });
291
363
 
292
364
  server.on("error", (error) => {
@@ -297,6 +369,10 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
297
369
 
298
370
  return () => {
299
371
  server.stop();
372
+ for (const timer of cleanupTimers.current.values()) {
373
+ clearTimeout(timer);
374
+ }
375
+ cleanupTimers.current.clear();
300
376
  };
301
377
  }, []); // Run once on mount
302
378
 
package/src/server.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
2
  import { StaticServer } from "react-native-nitro-http-server";
3
- import { Paths } from "expo-file-system/next";
3
+ import { Paths } from "expo-file-system";
4
4
  import { getBestIpAddress } from "./network";
5
5
  import { DEFAULT_HTTP_PORT, toErrorMessage } from "@couch-kit/core";
6
6
 
@@ -58,7 +58,7 @@ export const useStaticServer = (config: CouchKitHostConfig) => {
58
58
  if (!bundleUri) {
59
59
  throw new Error(
60
60
  "No staticDir provided and Paths.bundle is unavailable. " +
61
- "On Android, you must pass staticDir from useExtractAssets.",
61
+ "On Android, you must pass staticDir from useExtractAssets.",
62
62
  );
63
63
  }
64
64
  path = `${bundleUri.replace(/^file:\/\//, "")}www`;