@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 +39 -5
- package/lib/provider.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/assets.ts +1 -1
- package/src/provider.tsx +97 -21
- package/src/server.ts +2 -2
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`)
|
|
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
|
}}
|
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.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.
|
|
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
|
|
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
|
|
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 ->
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
dispatch({
|
|
222
|
-
type: InternalActionTypes.PLAYER_JOINED,
|
|
223
|
-
payload: { id: socketId, ...payload },
|
|
224
|
-
} as InternalAction<S>);
|
|
237
|
+
const playerId = derivePlayerId(secret);
|
|
225
238
|
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
239
|
+
// Update session maps
|
|
240
|
+
sessions.current.set(secret, socketId);
|
|
241
|
+
reverseMap.current.set(socketId, secret);
|
|
229
242
|
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
282
|
-
|
|
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
|
|
344
|
+
payload: { playerId },
|
|
287
345
|
} as InternalAction<S>);
|
|
288
346
|
|
|
289
|
-
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);
|
|
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
|
|
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
|
-
|
|
61
|
+
"On Android, you must pass staticDir from useExtractAssets.",
|
|
62
62
|
);
|
|
63
63
|
}
|
|
64
64
|
path = `${bundleUri.replace(/^file:\/\//, "")}www`;
|