@couch-kit/host 1.2.7 → 1.3.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
@@ -73,12 +73,16 @@ Returns:
73
73
 
74
74
  ## System Actions
75
75
 
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.**
76
+ 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
77
 
78
78
  Player tracking (`state.players`) is managed automatically:
79
79
 
80
80
  - When a player joins, they are added to `state.players` with `connected: true`.
81
81
  - When a player disconnects, they are marked as `connected: false`.
82
+ - 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.).
83
+ - If a disconnected player does not reconnect within the timeout window (default: 5 minutes), they are permanently removed from `state.players` via `__PLAYER_REMOVED__`.
84
+
85
+ Game logic that iterates over `state.players` should account for players being removed after the timeout.
82
86
 
83
87
  To react to player events outside of state (e.g., logging, analytics), use the callback config options:
84
88
 
@@ -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.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": {
@@ -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/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