@couch-kit/host 1.2.6 → 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,qBA+NA;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.6",
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,
@@ -123,6 +125,27 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
123
125
  stateRef.current = state;
124
126
  }, [state]);
125
127
 
128
+ // Send WELCOME messages after state has settled (post-render).
129
+ // This guarantees the joining player is included in the state snapshot.
130
+ useEffect(() => {
131
+ if (pendingWelcome.current.size === 0) return;
132
+ if (!wsServer.current) return;
133
+
134
+ const server = wsServer.current;
135
+ for (const [socketId, playerId] of pendingWelcome.current) {
136
+ welcomedClients.current.add(socketId);
137
+ server.send(socketId, {
138
+ type: MessageTypes.WELCOME,
139
+ payload: {
140
+ playerId,
141
+ state,
142
+ serverTime: Date.now(),
143
+ },
144
+ });
145
+ }
146
+ pendingWelcome.current.clear();
147
+ }, [state]);
148
+
126
149
  // Keep refs for callback props to avoid stale closures
127
150
  const configRef = useRef(config);
128
151
  useEffect(() => {
@@ -141,12 +164,23 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
141
164
  // 2. Start WebSocket Server (Convention: HTTP port + 2, avoids Metro on 8081)
142
165
  const wsServer = useRef<GameWebSocketServer | null>(null);
143
166
 
144
- // Track active sessions: secret -> playerId
167
+ // Track active sessions: secret -> socketId
145
168
  const sessions = useRef<Map<string, string>>(new Map());
146
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
+
147
178
  // Track socket IDs that have received their WELCOME message
148
179
  const welcomedClients = useRef<Set<string>>(new Set());
149
180
 
181
+ // Track socket IDs that need a WELCOME message after state settles
182
+ const pendingWelcome = useRef<Map<string, string>>(new Map()); // socketId -> playerId
183
+
150
184
  useEffect(() => {
151
185
  const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
152
186
  const server = new GameWebSocketServer({ port, debug: config.debug });
@@ -188,33 +222,51 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
188
222
  case MessageTypes.JOIN: {
189
223
  const { secret, ...payload } = message.payload;
190
224
 
191
- if (secret) {
192
- // Update the session map with the new socket ID for this secret
193
- sessions.current.set(secret, socketId);
194
- }
195
-
196
- // Dispatch the internal PLAYER_JOINED action
197
- dispatch({
198
- type: InternalActionTypes.PLAYER_JOINED,
199
- payload: { id: socketId, ...payload },
200
- } as InternalAction<S>);
201
-
202
- // Use queueMicrotask to send WELCOME after the reducer has processed
203
- // the PLAYER_JOINED action, so the client receives state that includes
204
- // themselves in the players list.
205
- queueMicrotask(() => {
206
- welcomedClients.current.add(socketId);
225
+ // Validate secret format
226
+ if (!secret || !isValidSecret(secret)) {
207
227
  server.send(socketId, {
208
- type: MessageTypes.WELCOME,
228
+ type: MessageTypes.ERROR,
209
229
  payload: {
210
- playerId: socketId,
211
- state: stateRef.current,
212
- serverTime: Date.now(),
230
+ code: "INVALID_SECRET",
231
+ message: "Invalid or missing session secret",
213
232
  },
214
233
  });
215
- });
234
+ return;
235
+ }
236
+
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
+ }
216
249
 
217
- configRef.current.onPlayerJoined?.(socketId, payload.name);
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);
218
270
  break;
219
271
  }
220
272
 
@@ -225,7 +277,9 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
225
277
  if (
226
278
  actionPayload.type === InternalActionTypes.HYDRATE ||
227
279
  actionPayload.type === InternalActionTypes.PLAYER_JOINED ||
228
- actionPayload.type === InternalActionTypes.PLAYER_LEFT
280
+ actionPayload.type === InternalActionTypes.PLAYER_LEFT ||
281
+ actionPayload.type === InternalActionTypes.PLAYER_RECONNECTED ||
282
+ actionPayload.type === InternalActionTypes.PLAYER_REMOVED
229
283
  ) {
230
284
  if (configRef.current.debug)
231
285
  console.warn(
@@ -242,7 +296,12 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
242
296
  });
243
297
  return;
244
298
  }
245
- 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 });
246
305
  break;
247
306
  }
248
307
 
@@ -265,15 +324,41 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
265
324
 
266
325
  welcomedClients.current.delete(socketId);
267
326
 
268
- // We do NOT remove the session from the map here,
269
- // 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);
270
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
+ }
340
+
341
+ // Mark disconnected (don't remove from sessions — allow reconnect)
271
342
  dispatch({
272
343
  type: InternalActionTypes.PLAYER_LEFT,
273
- payload: { playerId: socketId },
344
+ payload: { playerId },
274
345
  } as InternalAction<S>);
275
346
 
276
- 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);
277
362
  });
278
363
 
279
364
  server.on("error", (error) => {
@@ -284,13 +369,16 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
284
369
 
285
370
  return () => {
286
371
  server.stop();
372
+ for (const timer of cleanupTimers.current.values()) {
373
+ clearTimeout(timer);
374
+ }
375
+ cleanupTimers.current.clear();
287
376
  };
288
377
  }, []); // Run once on mount
289
378
 
290
379
  // 3. Throttled State Broadcasts (~30fps)
291
380
  // Batches rapid state changes so at most one broadcast is sent per ~33ms frame,
292
381
  // reducing serialization overhead and network traffic for fast-updating games.
293
- const broadcastPending = useRef(false);
294
382
  const broadcastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
295
383
 
296
384
  const broadcastState = useCallback(() => {
@@ -303,14 +391,15 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
303
391
  },
304
392
  });
305
393
  }
306
- broadcastPending.current = false;
307
394
  }, []);
308
395
 
309
396
  useEffect(() => {
310
- if (!broadcastPending.current) {
311
- broadcastPending.current = true;
312
- broadcastTimer.current = setTimeout(broadcastState, 33); // ~30fps
397
+ // Cancel any pending broadcast and schedule a fresh one.
398
+ // This ensures the broadcast always uses the latest stateRef.
399
+ if (broadcastTimer.current) {
400
+ clearTimeout(broadcastTimer.current);
313
401
  }
402
+ broadcastTimer.current = setTimeout(broadcastState, 33); // ~30fps
314
403
 
315
404
  return () => {
316
405
  if (broadcastTimer.current) {