@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 +5 -1
- package/lib/provider.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/provider.tsx +123 -34
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
|
|
package/lib/provider.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,
|
|
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.
|
|
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.
|
|
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 ->
|
|
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
|
-
|
|
192
|
-
|
|
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.
|
|
228
|
+
type: MessageTypes.ERROR,
|
|
209
229
|
payload: {
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
269
|
-
|
|
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
|
|
344
|
+
payload: { playerId },
|
|
274
345
|
} as InternalAction<S>);
|
|
275
346
|
|
|
276
|
-
configRef.current.onPlayerLeft?.(
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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) {
|