@couch-kit/host 1.7.9 → 1.7.11
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 +55 -6
- package/lib/broadcast-scheduler.d.ts +30 -0
- package/lib/broadcast-scheduler.d.ts.map +1 -0
- package/lib/index.d.ts +4 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/message-validation.d.ts +20 -0
- package/lib/message-validation.d.ts.map +1 -0
- package/lib/provider.d.ts +2 -0
- package/lib/provider.d.ts.map +1 -1
- package/lib/rate-limiter.d.ts +32 -0
- package/lib/rate-limiter.d.ts.map +1 -0
- package/lib/session-manager.d.ts +62 -0
- package/lib/session-manager.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/broadcast-scheduler.ts +84 -0
- package/src/index.tsx +4 -0
- package/src/message-validation.ts +58 -0
- package/src/provider.tsx +87 -192
- package/src/rate-limiter.ts +66 -0
- package/src/session-manager.ts +192 -0
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-
|
|
29
|
+
bun add 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`) 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
|
|
|
@@ -39,9 +39,8 @@ bun add react-native-tcp-socket react-native-nitro-modules
|
|
|
39
39
|
| `react-native` | `>= 0.72.0` |
|
|
40
40
|
| `react-native-nitro-modules` | `>= 0.33.0` |
|
|
41
41
|
| `expo` | `>= 51.0.0` |
|
|
42
|
-
| `expo-file-system` | `>=
|
|
42
|
+
| `expo-file-system` | `>= 19.0.0` |
|
|
43
43
|
| `expo-network` | `>= 7.0.0` |
|
|
44
|
-
| `react-native-tcp-socket` | `>= 6.0.0` |
|
|
45
44
|
|
|
46
45
|
> **New Architecture:** This package supports React Native's New Architecture (Fabric/TurboModules) via React Native 0.83+.
|
|
47
46
|
|
|
@@ -58,6 +57,8 @@ Config:
|
|
|
58
57
|
- `staticDir?`: absolute path to the directory of static files to serve. **Required on Android** — APK assets live inside a zip archive and cannot be served directly, so use this to point to a writable filesystem path where you've extracted the `www/` assets at runtime. On iOS, defaults to the bundle directory + `/www`.
|
|
59
58
|
- `devMode?`: if true, do not start the TV static file server; instead point phones at `devServerUrl`
|
|
60
59
|
- `devServerUrl?`: URL of your laptop dev server (e.g. `http://192.168.1.50:5173`)
|
|
60
|
+
- `stateThrottleMs?`: minimum interval (ms) between state broadcasts (default `33` ≈ 30fps)
|
|
61
|
+
- `disconnectTimeout?`: time (ms) before a disconnected player is permanently removed (default `300000` = 5 minutes)
|
|
61
62
|
- `debug?`: enable verbose logs
|
|
62
63
|
|
|
63
64
|
### `useGameHost()`
|
|
@@ -183,8 +184,8 @@ function GameScreen() {
|
|
|
183
184
|
|
|
184
185
|
To iterate on your web controller without rebuilding the Android app constantly:
|
|
185
186
|
|
|
186
|
-
1.
|
|
187
|
-
2.
|
|
187
|
+
1. Start your web project locally (`vite dev` usually runs on `localhost:5173`).
|
|
188
|
+
2. Configure the Host to point to your laptop:
|
|
188
189
|
|
|
189
190
|
```tsx
|
|
190
191
|
<GameHostProvider
|
|
@@ -206,3 +207,51 @@ Important: when the controller is served from the laptop, the client-side hook c
|
|
|
206
207
|
In production, the host serves static controller assets from the iOS bundle directory + `/www` by default. On Android, `staticDir` must be provided since bundle assets live inside the APK.
|
|
207
208
|
|
|
208
209
|
The CLI `couch-kit bundle` copies your web build output into `android/app/src/main/assets/www` (default). Ensure your app packaging makes those assets available under the expected `www` folder.
|
|
210
|
+
|
|
211
|
+
## Additional Hooks
|
|
212
|
+
|
|
213
|
+
### `useActionRecorder()`
|
|
214
|
+
|
|
215
|
+
Records game actions with timestamps for session replay and debugging. Useful for testing, bug reproduction, and replay-based features.
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
import { useActionRecorder, useGameHost } from "@couch-kit/host";
|
|
219
|
+
|
|
220
|
+
function GameScreen() {
|
|
221
|
+
const { state } = useGameHost();
|
|
222
|
+
const { isRecording, recordedCount, startRecording, stopRecording, recordAction } =
|
|
223
|
+
useActionRecorder();
|
|
224
|
+
|
|
225
|
+
const handleStartRecording = () => startRecording(state);
|
|
226
|
+
const handleStop = () => {
|
|
227
|
+
const recording = stopRecording();
|
|
228
|
+
// Save recording JSON for later replay via `couch-kit replay`
|
|
229
|
+
console.log(JSON.stringify(recording));
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<View>
|
|
234
|
+
<Text>Actions recorded: {recordedCount}</Text>
|
|
235
|
+
<Button title={isRecording ? "Stop" : "Record"} onPress={isRecording ? handleStop : handleStartRecording} />
|
|
236
|
+
</View>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
|
|
243
|
+
- `isRecording`: whether a recording session is active
|
|
244
|
+
- `recordedCount`: number of actions captured so far
|
|
245
|
+
- `startRecording(currentState, metadata?)`: begin recording from the given state
|
|
246
|
+
- `stopRecording()`: end recording and return the `ActionRecording` object (or `null`)
|
|
247
|
+
- `recordAction(action, state)`: capture a single action + resulting state
|
|
248
|
+
|
|
249
|
+
### `getBestIpAddress()`
|
|
250
|
+
|
|
251
|
+
Returns the device's best LAN IPv4 address (or `null`). Used internally by the provider but exported for custom networking UI (e.g., displaying a manual connect URL).
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
import { getBestIpAddress } from "@couch-kit/host";
|
|
255
|
+
|
|
256
|
+
const ip = await getBestIpAddress(); // e.g. "192.168.1.99"
|
|
257
|
+
```
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { MessageTypes, type HostMessage } from "@couch-kit/core";
|
|
2
|
+
/** Default state broadcast throttle (~30fps). */
|
|
3
|
+
export declare const DEFAULT_STATE_THROTTLE_MS = 33;
|
|
4
|
+
export type StateUpdateMessage = Extract<HostMessage, {
|
|
5
|
+
type: typeof MessageTypes.STATE_UPDATE;
|
|
6
|
+
}>;
|
|
7
|
+
export interface TimerScheduler<TTimer> {
|
|
8
|
+
setTimeout(callback: () => void, delayMs: number): TTimer;
|
|
9
|
+
clearTimeout(timer: TTimer): void;
|
|
10
|
+
}
|
|
11
|
+
export interface BroadcastSchedulerOptions<TTimer> {
|
|
12
|
+
stateThrottleMs?: number;
|
|
13
|
+
scheduler?: TimerScheduler<TTimer>;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Debounced state-broadcast scheduler used by the host provider.
|
|
17
|
+
* Rapid state changes are coalesced into one broadcast after the latest change.
|
|
18
|
+
*/
|
|
19
|
+
export declare class BroadcastScheduler<TTimer = ReturnType<typeof setTimeout>> {
|
|
20
|
+
private stateThrottleMs;
|
|
21
|
+
private readonly scheduler;
|
|
22
|
+
private timer;
|
|
23
|
+
constructor(options?: BroadcastSchedulerOptions<TTimer>);
|
|
24
|
+
schedule(callback: () => void): void;
|
|
25
|
+
cancel(): void;
|
|
26
|
+
setStateThrottleMs(stateThrottleMs: number): void;
|
|
27
|
+
hasPendingBroadcast(): boolean;
|
|
28
|
+
}
|
|
29
|
+
export declare function createStateUpdateMessage(newState: unknown, actions: readonly unknown[], timestamp?: number): StateUpdateMessage;
|
|
30
|
+
//# sourceMappingURL=broadcast-scheduler.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"broadcast-scheduler.d.ts","sourceRoot":"","sources":["../src/broadcast-scheduler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAEjE,iDAAiD;AACjD,eAAO,MAAM,yBAAyB,KAAK,CAAC;AAE5C,MAAM,MAAM,kBAAkB,GAAG,OAAO,CACtC,WAAW,EACX;IAAE,IAAI,EAAE,OAAO,YAAY,CAAC,YAAY,CAAA;CAAE,CAC3C,CAAC;AAEF,MAAM,WAAW,cAAc,CAAC,MAAM;IACpC,UAAU,CAAC,QAAQ,EAAE,MAAM,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC;IAC1D,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAOD,MAAM,WAAW,yBAAyB,CAAC,MAAM;IAC/C,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,cAAc,CAAC,MAAM,CAAC,CAAC;CACpC;AAED;;;GAGG;AACH,qBAAa,kBAAkB,CAAC,MAAM,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC;IACpE,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAyB;IACnD,OAAO,CAAC,KAAK,CAAuB;gBAExB,OAAO,GAAE,yBAAyB,CAAC,MAAM,CAAM;IAQ3D,QAAQ,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,IAAI;IAQpC,MAAM,IAAI,IAAI;IAOd,kBAAkB,CAAC,eAAe,EAAE,MAAM,GAAG,IAAI;IAIjD,mBAAmB,IAAI,OAAO;CAG/B;AAED,wBAAgB,wBAAwB,CACtC,QAAQ,EAAE,OAAO,EACjB,OAAO,EAAE,SAAS,OAAO,EAAE,EAC3B,SAAS,GAAE,MAAmB,GAC7B,kBAAkB,CAapB"}
|
package/lib/index.d.ts
CHANGED
|
@@ -4,4 +4,8 @@ export * from "./websocket";
|
|
|
4
4
|
export * from "./network";
|
|
5
5
|
export * from "./assets";
|
|
6
6
|
export * from "./action-recorder";
|
|
7
|
+
export * from "./rate-limiter";
|
|
8
|
+
export * from "./session-manager";
|
|
9
|
+
export * from "./message-validation";
|
|
10
|
+
export * from "./broadcast-scheduler";
|
|
7
11
|
//# sourceMappingURL=index.d.ts.map
|
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,mBAAmB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.tsx"],"names":[],"mappings":"AAAA,cAAc,YAAY,CAAC;AAC3B,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,mBAAmB,CAAC;AAClC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,uBAAuB,CAAC"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { MessageTypes, type ClientMessage } from "@couch-kit/core";
|
|
2
|
+
type ClientMessageOf<TType extends ClientMessage["type"]> = Extract<ClientMessage, {
|
|
3
|
+
type: TType;
|
|
4
|
+
}>;
|
|
5
|
+
export type ValidatedClientMessage = {
|
|
6
|
+
type: typeof MessageTypes.JOIN;
|
|
7
|
+
payload: {
|
|
8
|
+
name: string;
|
|
9
|
+
secret?: unknown;
|
|
10
|
+
avatar?: unknown;
|
|
11
|
+
[key: string]: unknown;
|
|
12
|
+
};
|
|
13
|
+
} | ClientMessageOf<"ACTION"> | ClientMessageOf<"PING"> | ClientMessageOf<"ASSETS_LOADED">;
|
|
14
|
+
/**
|
|
15
|
+
* Validates that an incoming message has the expected shape.
|
|
16
|
+
* Returns true if the message has a processable client-message shape.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isValidClientMessage(msg: unknown): msg is ValidatedClientMessage;
|
|
19
|
+
export {};
|
|
20
|
+
//# sourceMappingURL=message-validation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"message-validation.d.ts","sourceRoot":"","sources":["../src/message-validation.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEnE,KAAK,eAAe,CAAC,KAAK,SAAS,aAAa,CAAC,MAAM,CAAC,IAAI,OAAO,CACjE,aAAa,EACb;IAAE,IAAI,EAAE,KAAK,CAAA;CAAE,CAChB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAC9B;IACE,IAAI,EAAE,OAAO,YAAY,CAAC,IAAI,CAAC;IAC/B,OAAO,EAAE;QACP,IAAI,EAAE,MAAM,CAAC;QACb,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,MAAM,CAAC,EAAE,OAAO,CAAC;QACjB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;KACxB,CAAC;CACH,GACD,eAAe,CAAC,QAAQ,CAAC,GACzB,eAAe,CAAC,MAAM,CAAC,GACvB,eAAe,CAAC,eAAe,CAAC,CAAC;AAErC;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,OAAO,GACX,GAAG,IAAI,sBAAsB,CA8B/B"}
|
package/lib/provider.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
|
11
11
|
debug?: boolean;
|
|
12
12
|
/** Timeout (ms) before a disconnected player is permanently removed (default: 5 minutes). */
|
|
13
13
|
disconnectTimeout?: number;
|
|
14
|
+
/** State broadcast throttle interval in milliseconds (default: 33ms, ~30fps). */
|
|
15
|
+
stateThrottleMs?: number;
|
|
14
16
|
/** Called when a player successfully joins. */
|
|
15
17
|
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
16
18
|
/** Called when a player disconnects. */
|
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,EAEb,MAAM,iBAAiB,CAAC;AAazB,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,6FAA6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,iFAAiF;IACjF,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,+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;AAQD;;;;;;;;;;;;;;;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,qBA2WA;AAED;;;;;;;;;GASG;AACH,wBAAgB,WAAW,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,KAK/C,oBAAoB,CAAC,CAAC,EAAE,CAAC,CAAC,CAC7C"}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** Maximum actions per rate-limit window. */
|
|
2
|
+
export declare const RATE_LIMIT_MAX = 60;
|
|
3
|
+
/** Rate-limit window duration (ms). */
|
|
4
|
+
export declare const RATE_LIMIT_WINDOW = 1000;
|
|
5
|
+
export interface RateLimitInfo {
|
|
6
|
+
count: number;
|
|
7
|
+
windowStart: number;
|
|
8
|
+
}
|
|
9
|
+
export interface RateLimitResult extends RateLimitInfo {
|
|
10
|
+
allowed: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface ActionRateLimiterOptions {
|
|
13
|
+
maxActions?: number;
|
|
14
|
+
windowMs?: number;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Per-socket action limiter that preserves the host provider's original
|
|
19
|
+
* fixed-window algorithm.
|
|
20
|
+
*/
|
|
21
|
+
export declare class ActionRateLimiter {
|
|
22
|
+
private readonly maxActions;
|
|
23
|
+
private readonly windowMs;
|
|
24
|
+
private readonly now;
|
|
25
|
+
private readonly limits;
|
|
26
|
+
constructor(options?: ActionRateLimiterOptions);
|
|
27
|
+
record(socketId: string): RateLimitResult;
|
|
28
|
+
reset(socketId: string): void;
|
|
29
|
+
clear(): void;
|
|
30
|
+
get(socketId: string): RateLimitInfo | undefined;
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=rate-limiter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limiter.d.ts","sourceRoot":"","sources":["../src/rate-limiter.ts"],"names":[],"mappings":"AAAA,6CAA6C;AAC7C,eAAO,MAAM,cAAc,KAAK,CAAC;AAEjC,uCAAuC;AACvC,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,EAAE,MAAM,CAAC;CACrB;AAED,MAAM,WAAW,eAAgB,SAAQ,aAAa;IACpD,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,MAAM,WAAW,wBAAwB;IACvC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CACpB;AAED;;;GAGG;AACH,qBAAa,iBAAiB;IAC5B,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAClC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAoC;gBAE/C,OAAO,GAAE,wBAA6B;IAMlD,MAAM,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe;IAiBzC,KAAK,CAAC,QAAQ,EAAE,MAAM,GAAG,IAAI;IAI7B,KAAK,IAAI,IAAI;IAIb,GAAG,CAAC,QAAQ,EAAE,MAAM,GAAG,aAAa,GAAG,SAAS;CAGjD"}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type IGameState, type InternalAction } from "@couch-kit/core";
|
|
2
|
+
export interface SessionTimerScheduler<TTimer> {
|
|
3
|
+
setTimeout(callback: () => void, delayMs: number): TTimer;
|
|
4
|
+
clearTimeout(timer: TTimer): void;
|
|
5
|
+
}
|
|
6
|
+
export interface JoinSessionPayload {
|
|
7
|
+
name: string;
|
|
8
|
+
avatar?: string;
|
|
9
|
+
secret: string;
|
|
10
|
+
}
|
|
11
|
+
export interface HostSessionManagerOptions<TTimer> {
|
|
12
|
+
disconnectTimeout?: number;
|
|
13
|
+
getDisconnectTimeout?: () => number;
|
|
14
|
+
scheduler?: SessionTimerScheduler<TTimer>;
|
|
15
|
+
derivePlayerId?: (secret: string) => Promise<string>;
|
|
16
|
+
derivePlayerIdLegacy?: (secret: string) => string;
|
|
17
|
+
}
|
|
18
|
+
export type JoinSessionResult<S extends IGameState> = {
|
|
19
|
+
playerId: string;
|
|
20
|
+
socketId: string;
|
|
21
|
+
secret: string;
|
|
22
|
+
isReconnect: boolean;
|
|
23
|
+
action: InternalAction<S>;
|
|
24
|
+
};
|
|
25
|
+
export type DisconnectSessionResult<S extends IGameState> = {
|
|
26
|
+
kind: "unknown";
|
|
27
|
+
} | {
|
|
28
|
+
kind: "stale";
|
|
29
|
+
playerId: string;
|
|
30
|
+
secret: string;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "left";
|
|
33
|
+
playerId: string;
|
|
34
|
+
secret: string;
|
|
35
|
+
action: InternalAction<S>;
|
|
36
|
+
};
|
|
37
|
+
type PlayersSource<S extends IGameState> = S["players"] | (() => S["players"]);
|
|
38
|
+
/**
|
|
39
|
+
* Tracks host player sessions by secret and socket ID, including reconnects and
|
|
40
|
+
* delayed cleanup of disconnected players.
|
|
41
|
+
*/
|
|
42
|
+
export declare class HostSessionManager<TTimer = ReturnType<typeof setTimeout>> {
|
|
43
|
+
private readonly sessions;
|
|
44
|
+
private readonly reverseMap;
|
|
45
|
+
private readonly cleanupTimers;
|
|
46
|
+
private readonly socketIdToPlayerId;
|
|
47
|
+
private readonly scheduler;
|
|
48
|
+
private readonly getDisconnectTimeout;
|
|
49
|
+
private readonly derivePlayerIdFn;
|
|
50
|
+
private readonly derivePlayerIdLegacyFn;
|
|
51
|
+
constructor(options?: HostSessionManagerOptions<TTimer>);
|
|
52
|
+
handleJoin<S extends IGameState>(socketId: string, payload: JoinSessionPayload, playersSource: PlayersSource<S>): Promise<JoinSessionResult<S>>;
|
|
53
|
+
handleDisconnect<S extends IGameState>(socketId: string): DisconnectSessionResult<S>;
|
|
54
|
+
scheduleRemoval(playerId: string, secret: string, onRemove: (playerId: string) => void): void;
|
|
55
|
+
cancelRemoval(playerId: string): void;
|
|
56
|
+
clearRemovalTimers(): void;
|
|
57
|
+
getPlayerIdForSocket(socketId: string): string | undefined;
|
|
58
|
+
getSocketIdForSecret(secret: string): string | undefined;
|
|
59
|
+
hasPendingRemoval(playerId: string): boolean;
|
|
60
|
+
}
|
|
61
|
+
export {};
|
|
62
|
+
//# sourceMappingURL=session-manager.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"session-manager.d.ts","sourceRoot":"","sources":["../src/session-manager.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@couch-kit/host",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.11",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"provenance": true
|
|
@@ -58,13 +58,13 @@
|
|
|
58
58
|
"prepublishOnly": "bun run build"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"@couch-kit/core": "0.9.
|
|
61
|
+
"@couch-kit/core": "0.9.2",
|
|
62
62
|
"buffer": "^6.0.3",
|
|
63
63
|
"js-sha1": "^0.7.0",
|
|
64
64
|
"react-native-nitro-http-server": "^1.6.1"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
|
-
"@types/react": "^19.
|
|
67
|
+
"@types/react": "^19.2.17",
|
|
68
68
|
"react": "19.0.4",
|
|
69
69
|
"react-native": "0.83.2",
|
|
70
70
|
"del-cli": "^5.1.0",
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { MessageTypes, type HostMessage } from "@couch-kit/core";
|
|
2
|
+
|
|
3
|
+
/** Default state broadcast throttle (~30fps). */
|
|
4
|
+
export const DEFAULT_STATE_THROTTLE_MS = 33;
|
|
5
|
+
|
|
6
|
+
export type StateUpdateMessage = Extract<
|
|
7
|
+
HostMessage,
|
|
8
|
+
{ type: typeof MessageTypes.STATE_UPDATE }
|
|
9
|
+
>;
|
|
10
|
+
|
|
11
|
+
export interface TimerScheduler<TTimer> {
|
|
12
|
+
setTimeout(callback: () => void, delayMs: number): TTimer;
|
|
13
|
+
clearTimeout(timer: TTimer): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const defaultTimerScheduler: TimerScheduler<ReturnType<typeof setTimeout>> = {
|
|
17
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
18
|
+
clearTimeout: (timer) => clearTimeout(timer),
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export interface BroadcastSchedulerOptions<TTimer> {
|
|
22
|
+
stateThrottleMs?: number;
|
|
23
|
+
scheduler?: TimerScheduler<TTimer>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Debounced state-broadcast scheduler used by the host provider.
|
|
28
|
+
* Rapid state changes are coalesced into one broadcast after the latest change.
|
|
29
|
+
*/
|
|
30
|
+
export class BroadcastScheduler<TTimer = ReturnType<typeof setTimeout>> {
|
|
31
|
+
private stateThrottleMs: number;
|
|
32
|
+
private readonly scheduler: TimerScheduler<TTimer>;
|
|
33
|
+
private timer: TTimer | null = null;
|
|
34
|
+
|
|
35
|
+
constructor(options: BroadcastSchedulerOptions<TTimer> = {}) {
|
|
36
|
+
this.stateThrottleMs =
|
|
37
|
+
options.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS;
|
|
38
|
+
this.scheduler =
|
|
39
|
+
options.scheduler ??
|
|
40
|
+
(defaultTimerScheduler as unknown as TimerScheduler<TTimer>);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
schedule(callback: () => void): void {
|
|
44
|
+
this.cancel();
|
|
45
|
+
this.timer = this.scheduler.setTimeout(() => {
|
|
46
|
+
this.timer = null;
|
|
47
|
+
callback();
|
|
48
|
+
}, this.stateThrottleMs);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
cancel(): void {
|
|
52
|
+
if (this.timer) {
|
|
53
|
+
this.scheduler.clearTimeout(this.timer);
|
|
54
|
+
this.timer = null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
setStateThrottleMs(stateThrottleMs: number): void {
|
|
59
|
+
this.stateThrottleMs = stateThrottleMs;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hasPendingBroadcast(): boolean {
|
|
63
|
+
return this.timer !== null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function createStateUpdateMessage(
|
|
68
|
+
newState: unknown,
|
|
69
|
+
actions: readonly unknown[],
|
|
70
|
+
timestamp: number = Date.now(),
|
|
71
|
+
): StateUpdateMessage {
|
|
72
|
+
return {
|
|
73
|
+
type: MessageTypes.STATE_UPDATE,
|
|
74
|
+
payload: {
|
|
75
|
+
newState,
|
|
76
|
+
timestamp,
|
|
77
|
+
...(actions.length === 1
|
|
78
|
+
? { action: actions[0] }
|
|
79
|
+
: actions.length > 1
|
|
80
|
+
? { action: actions }
|
|
81
|
+
: {}),
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
}
|
package/src/index.tsx
CHANGED
|
@@ -4,3 +4,7 @@ export * from "./websocket";
|
|
|
4
4
|
export * from "./network";
|
|
5
5
|
export * from "./assets";
|
|
6
6
|
export * from "./action-recorder";
|
|
7
|
+
export * from "./rate-limiter";
|
|
8
|
+
export * from "./session-manager";
|
|
9
|
+
export * from "./message-validation";
|
|
10
|
+
export * from "./broadcast-scheduler";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { MessageTypes, type ClientMessage } from "@couch-kit/core";
|
|
2
|
+
|
|
3
|
+
type ClientMessageOf<TType extends ClientMessage["type"]> = Extract<
|
|
4
|
+
ClientMessage,
|
|
5
|
+
{ type: TType }
|
|
6
|
+
>;
|
|
7
|
+
|
|
8
|
+
export type ValidatedClientMessage =
|
|
9
|
+
| {
|
|
10
|
+
type: typeof MessageTypes.JOIN;
|
|
11
|
+
payload: {
|
|
12
|
+
name: string;
|
|
13
|
+
secret?: unknown;
|
|
14
|
+
avatar?: unknown;
|
|
15
|
+
[key: string]: unknown;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
| ClientMessageOf<"ACTION">
|
|
19
|
+
| ClientMessageOf<"PING">
|
|
20
|
+
| ClientMessageOf<"ASSETS_LOADED">;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Validates that an incoming message has the expected shape.
|
|
24
|
+
* Returns true if the message has a processable client-message shape.
|
|
25
|
+
*/
|
|
26
|
+
export function isValidClientMessage(
|
|
27
|
+
msg: unknown,
|
|
28
|
+
): msg is ValidatedClientMessage {
|
|
29
|
+
if (typeof msg !== "object" || msg === null) return false;
|
|
30
|
+
const m = msg as Record<string, unknown>;
|
|
31
|
+
if (typeof m.type !== "string") return false;
|
|
32
|
+
|
|
33
|
+
switch (m.type) {
|
|
34
|
+
case MessageTypes.JOIN:
|
|
35
|
+
return (
|
|
36
|
+
typeof m.payload === "object" &&
|
|
37
|
+
m.payload !== null &&
|
|
38
|
+
typeof (m.payload as Record<string, unknown>).name === "string"
|
|
39
|
+
);
|
|
40
|
+
case MessageTypes.ACTION:
|
|
41
|
+
return (
|
|
42
|
+
typeof m.payload === "object" &&
|
|
43
|
+
m.payload !== null &&
|
|
44
|
+
typeof (m.payload as Record<string, unknown>).type === "string"
|
|
45
|
+
);
|
|
46
|
+
case MessageTypes.PING:
|
|
47
|
+
return (
|
|
48
|
+
typeof m.payload === "object" &&
|
|
49
|
+
m.payload !== null &&
|
|
50
|
+
typeof (m.payload as Record<string, unknown>).id === "string" &&
|
|
51
|
+
typeof (m.payload as Record<string, unknown>).timestamp === "number"
|
|
52
|
+
);
|
|
53
|
+
case MessageTypes.ASSETS_LOADED:
|
|
54
|
+
return m.payload === true;
|
|
55
|
+
default:
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/provider.tsx
CHANGED
|
@@ -16,20 +16,22 @@ import {
|
|
|
16
16
|
DEFAULT_WS_PORT_OFFSET,
|
|
17
17
|
DEFAULT_DISCONNECT_TIMEOUT,
|
|
18
18
|
createGameReducer,
|
|
19
|
-
derivePlayerId,
|
|
20
|
-
derivePlayerIdLegacy,
|
|
21
19
|
isValidSecret,
|
|
22
20
|
type IGameState,
|
|
23
21
|
type IAction,
|
|
24
22
|
type InternalAction,
|
|
25
|
-
type ClientMessage,
|
|
26
23
|
} from "@couch-kit/core";
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
import { isValidClientMessage } from "./message-validation";
|
|
25
|
+
import { ActionRateLimiter } from "./rate-limiter";
|
|
26
|
+
import {
|
|
27
|
+
HostSessionManager,
|
|
28
|
+
type JoinSessionPayload,
|
|
29
|
+
} from "./session-manager";
|
|
30
|
+
import {
|
|
31
|
+
BroadcastScheduler,
|
|
32
|
+
DEFAULT_STATE_THROTTLE_MS,
|
|
33
|
+
createStateUpdateMessage,
|
|
34
|
+
} from "./broadcast-scheduler";
|
|
33
35
|
|
|
34
36
|
export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
35
37
|
initialState: S;
|
|
@@ -42,6 +44,8 @@ export interface GameHostConfig<S extends IGameState, A extends IAction> {
|
|
|
42
44
|
debug?: boolean;
|
|
43
45
|
/** Timeout (ms) before a disconnected player is permanently removed (default: 5 minutes). */
|
|
44
46
|
disconnectTimeout?: number;
|
|
47
|
+
/** State broadcast throttle interval in milliseconds (default: 33ms, ~30fps). */
|
|
48
|
+
stateThrottleMs?: number;
|
|
45
49
|
/** Called when a player successfully joins. */
|
|
46
50
|
onPlayerJoined?: (playerId: string, name: string) => void;
|
|
47
51
|
/** Called when a player disconnects. */
|
|
@@ -63,42 +67,6 @@ const GameHostContext = createContext<GameHostContextValue<any, any> | null>(
|
|
|
63
67
|
null,
|
|
64
68
|
);
|
|
65
69
|
|
|
66
|
-
/**
|
|
67
|
-
* Validates that an incoming message has the expected shape.
|
|
68
|
-
* Returns true if the message is a valid ClientMessage, false otherwise.
|
|
69
|
-
*/
|
|
70
|
-
function isValidClientMessage(msg: unknown): msg is ClientMessage {
|
|
71
|
-
if (typeof msg !== "object" || msg === null) return false;
|
|
72
|
-
const m = msg as Record<string, unknown>;
|
|
73
|
-
if (typeof m.type !== "string") return false;
|
|
74
|
-
|
|
75
|
-
switch (m.type) {
|
|
76
|
-
case MessageTypes.JOIN:
|
|
77
|
-
return (
|
|
78
|
-
typeof m.payload === "object" &&
|
|
79
|
-
m.payload !== null &&
|
|
80
|
-
typeof (m.payload as Record<string, unknown>).name === "string"
|
|
81
|
-
);
|
|
82
|
-
case MessageTypes.ACTION:
|
|
83
|
-
return (
|
|
84
|
-
typeof m.payload === "object" &&
|
|
85
|
-
m.payload !== null &&
|
|
86
|
-
typeof (m.payload as Record<string, unknown>).type === "string"
|
|
87
|
-
);
|
|
88
|
-
case MessageTypes.PING:
|
|
89
|
-
return (
|
|
90
|
-
typeof m.payload === "object" &&
|
|
91
|
-
m.payload !== null &&
|
|
92
|
-
typeof (m.payload as Record<string, unknown>).id === "string" &&
|
|
93
|
-
typeof (m.payload as Record<string, unknown>).timestamp === "number"
|
|
94
|
-
);
|
|
95
|
-
case MessageTypes.ASSETS_LOADED:
|
|
96
|
-
return m.payload === true;
|
|
97
|
-
default:
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
70
|
/**
|
|
103
71
|
* React context provider that turns a React Native TV app into a local game server.
|
|
104
72
|
*
|
|
@@ -193,15 +161,11 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
193
161
|
// 2. Start WebSocket Server (Convention: HTTP port + 2, avoids Metro on 8081)
|
|
194
162
|
const wsServer = useRef<GameWebSocketServer | null>(null);
|
|
195
163
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
// Stale player cleanup timers: playerId -> timer
|
|
203
|
-
const cleanupTimers = useRef<Map<string, ReturnType<typeof setTimeout>>>(
|
|
204
|
-
new Map(),
|
|
164
|
+
const sessionManager = useRef(
|
|
165
|
+
new HostSessionManager({
|
|
166
|
+
getDisconnectTimeout: () =>
|
|
167
|
+
configRef.current.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT,
|
|
168
|
+
}),
|
|
205
169
|
);
|
|
206
170
|
|
|
207
171
|
// Track socket IDs that have received their WELCOME message
|
|
@@ -212,19 +176,13 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
212
176
|
Map<string, { playerId: string; isReconnect: boolean }>
|
|
213
177
|
>(new Map());
|
|
214
178
|
|
|
215
|
-
// Cache: socketId -> playerId (avoids async derivation in hot paths)
|
|
216
|
-
const socketIdToPlayerId = useRef<Map<string, string>>(new Map());
|
|
217
|
-
|
|
218
179
|
// Track which players have finished loading assets
|
|
219
180
|
const assetsLoaded = useRef<Map<string, boolean>>(new Map());
|
|
220
181
|
|
|
221
182
|
// Queue of actions dispatched since last broadcast (for STATE_UPDATE.action)
|
|
222
183
|
const actionQueue = useRef<unknown[]>([]);
|
|
223
184
|
|
|
224
|
-
|
|
225
|
-
const rateLimits = useRef<
|
|
226
|
-
Map<string, { count: number; windowStart: number }>
|
|
227
|
-
>(new Map());
|
|
185
|
+
const rateLimiter = useRef(new ActionRateLimiter());
|
|
228
186
|
|
|
229
187
|
useEffect(() => {
|
|
230
188
|
const port = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
@@ -274,7 +232,11 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
274
232
|
const { secret, ...payload } = message.payload;
|
|
275
233
|
|
|
276
234
|
// Validate secret format
|
|
277
|
-
if (
|
|
235
|
+
if (
|
|
236
|
+
!secret ||
|
|
237
|
+
typeof secret !== "string" ||
|
|
238
|
+
!isValidSecret(secret)
|
|
239
|
+
) {
|
|
278
240
|
server.send(socketId, {
|
|
279
241
|
type: MessageTypes.ERROR,
|
|
280
242
|
payload: {
|
|
@@ -285,80 +247,38 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
285
247
|
return;
|
|
286
248
|
}
|
|
287
249
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
stateRef.current.players[legacyId]
|
|
297
|
-
) {
|
|
298
|
-
playerId = legacyId;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
// Cache the resolved playerId for this socket
|
|
302
|
-
socketIdToPlayerId.current.set(socketId, playerId);
|
|
303
|
-
|
|
304
|
-
// Update session maps
|
|
305
|
-
sessions.current.set(secret, socketId);
|
|
306
|
-
reverseMap.current.set(socketId, secret);
|
|
307
|
-
|
|
308
|
-
// Cancel any pending cleanup timer for this player
|
|
309
|
-
const existingTimer = cleanupTimers.current.get(playerId);
|
|
310
|
-
if (existingTimer) {
|
|
311
|
-
clearTimeout(existingTimer);
|
|
312
|
-
cleanupTimers.current.delete(playerId);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// Check if this is a returning player
|
|
316
|
-
const existingPlayer = stateRef.current.players[playerId];
|
|
317
|
-
if (existingPlayer) {
|
|
318
|
-
// Reconnection — restore existing player
|
|
319
|
-
dispatch({
|
|
320
|
-
type: InternalActionTypes.PLAYER_RECONNECTED,
|
|
321
|
-
payload: { playerId },
|
|
322
|
-
} as InternalAction<S>);
|
|
323
|
-
|
|
324
|
-
// Reset assets loaded status on reconnect
|
|
325
|
-
assetsLoaded.current.set(playerId, false);
|
|
250
|
+
sessionManager.current
|
|
251
|
+
.handleJoin<S>(
|
|
252
|
+
socketId,
|
|
253
|
+
message.payload as JoinSessionPayload,
|
|
254
|
+
() => stateRef.current.players,
|
|
255
|
+
)
|
|
256
|
+
.then(({ playerId, isReconnect, action }) => {
|
|
257
|
+
dispatch(action);
|
|
326
258
|
|
|
327
|
-
//
|
|
328
|
-
pendingWelcome.current.set(socketId, {
|
|
329
|
-
playerId,
|
|
330
|
-
isReconnect: true,
|
|
331
|
-
});
|
|
332
|
-
} else {
|
|
333
|
-
// New player
|
|
334
|
-
dispatch({
|
|
335
|
-
type: InternalActionTypes.PLAYER_JOINED,
|
|
336
|
-
payload: { id: playerId, ...payload },
|
|
337
|
-
} as InternalAction<S>);
|
|
338
|
-
|
|
339
|
-
// Initialize assets loaded status
|
|
259
|
+
// Initialize/reset assets loaded status
|
|
340
260
|
assetsLoaded.current.set(playerId, false);
|
|
341
261
|
|
|
342
|
-
// Queue WELCOME message
|
|
262
|
+
// Queue WELCOME/RECONNECTED message
|
|
343
263
|
pendingWelcome.current.set(socketId, {
|
|
344
264
|
playerId,
|
|
345
|
-
isReconnect
|
|
265
|
+
isReconnect,
|
|
346
266
|
});
|
|
347
|
-
}
|
|
348
267
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
268
|
+
configRef.current.onPlayerJoined?.(playerId, payload.name);
|
|
269
|
+
})
|
|
270
|
+
.catch((err) => {
|
|
271
|
+
if (configRef.current.debug) {
|
|
272
|
+
console.error("[GameHost] Failed to derive player ID:", err);
|
|
273
|
+
}
|
|
274
|
+
server.send(socketId, {
|
|
275
|
+
type: MessageTypes.ERROR,
|
|
276
|
+
payload: {
|
|
277
|
+
code: "JOIN_FAILED",
|
|
278
|
+
message: "Failed to process join request",
|
|
279
|
+
},
|
|
280
|
+
});
|
|
360
281
|
});
|
|
361
|
-
});
|
|
362
282
|
break;
|
|
363
283
|
}
|
|
364
284
|
|
|
@@ -390,14 +310,7 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
390
310
|
}
|
|
391
311
|
|
|
392
312
|
// Rate limiting
|
|
393
|
-
|
|
394
|
-
let rateInfo = rateLimits.current.get(socketId);
|
|
395
|
-
if (!rateInfo || now - rateInfo.windowStart > RATE_LIMIT_WINDOW) {
|
|
396
|
-
rateInfo = { count: 0, windowStart: now };
|
|
397
|
-
rateLimits.current.set(socketId, rateInfo);
|
|
398
|
-
}
|
|
399
|
-
rateInfo.count++;
|
|
400
|
-
if (rateInfo.count > RATE_LIMIT_MAX) {
|
|
313
|
+
if (!rateLimiter.current.record(socketId).allowed) {
|
|
401
314
|
if (configRef.current.debug)
|
|
402
315
|
console.warn(`[GameHost] Rate limited ${socketId}`);
|
|
403
316
|
server.send(socketId, {
|
|
@@ -411,7 +324,8 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
411
324
|
}
|
|
412
325
|
|
|
413
326
|
// Use cached playerId (populated at JOIN time)
|
|
414
|
-
const resolvedPlayerId =
|
|
327
|
+
const resolvedPlayerId =
|
|
328
|
+
sessionManager.current.getPlayerIdForSocket(socketId);
|
|
415
329
|
dispatch({ ...actionPayload, playerId: resolvedPlayerId });
|
|
416
330
|
actionQueue.current.push(actionPayload);
|
|
417
331
|
break;
|
|
@@ -429,7 +343,8 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
429
343
|
break;
|
|
430
344
|
|
|
431
345
|
case MessageTypes.ASSETS_LOADED: {
|
|
432
|
-
const loadedPlayerId =
|
|
346
|
+
const loadedPlayerId =
|
|
347
|
+
sessionManager.current.getPlayerIdForSocket(socketId);
|
|
433
348
|
if (loadedPlayerId) {
|
|
434
349
|
assetsLoaded.current.set(loadedPlayerId, true);
|
|
435
350
|
if (configRef.current.debug)
|
|
@@ -446,48 +361,36 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
446
361
|
|
|
447
362
|
welcomedClients.current.delete(socketId);
|
|
448
363
|
|
|
449
|
-
// Use cached playerId
|
|
450
|
-
const playerId = socketIdToPlayerId.current.get(socketId);
|
|
451
|
-
socketIdToPlayerId.current.delete(socketId);
|
|
452
|
-
|
|
453
|
-
// Clean up reverse map
|
|
454
|
-
const secret = reverseMap.current.get(socketId);
|
|
455
|
-
reverseMap.current.delete(socketId);
|
|
456
|
-
|
|
457
364
|
// Clean up rate limits
|
|
458
|
-
|
|
365
|
+
rateLimiter.current.reset(socketId);
|
|
459
366
|
|
|
460
|
-
|
|
367
|
+
const result = sessionManager.current.handleDisconnect<S>(socketId);
|
|
368
|
+
if (result.kind === "unknown") return; // Unknown socket, nothing to do
|
|
461
369
|
|
|
462
370
|
// Clean up assets loaded tracking
|
|
463
|
-
assetsLoaded.current.delete(playerId);
|
|
371
|
+
assetsLoaded.current.delete(result.playerId);
|
|
464
372
|
|
|
465
|
-
|
|
466
|
-
if (sessions.current.get(secret) !== socketId) {
|
|
373
|
+
if (result.kind === "stale") {
|
|
467
374
|
// Player already reconnected on a newer socket — skip
|
|
468
375
|
return;
|
|
469
376
|
}
|
|
470
377
|
|
|
471
378
|
// Mark disconnected (don't remove from sessions — allow reconnect)
|
|
472
|
-
dispatch(
|
|
473
|
-
type: InternalActionTypes.PLAYER_LEFT,
|
|
474
|
-
payload: { playerId },
|
|
475
|
-
} as InternalAction<S>);
|
|
379
|
+
dispatch(result.action);
|
|
476
380
|
|
|
477
|
-
configRef.current.onPlayerLeft?.(playerId);
|
|
381
|
+
configRef.current.onPlayerLeft?.(result.playerId);
|
|
478
382
|
|
|
479
383
|
// Start stale player cleanup timer
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
cleanupTimers.current.set(playerId, timer);
|
|
384
|
+
sessionManager.current.scheduleRemoval(
|
|
385
|
+
result.playerId,
|
|
386
|
+
result.secret,
|
|
387
|
+
(playerId) => {
|
|
388
|
+
dispatch({
|
|
389
|
+
type: InternalActionTypes.PLAYER_REMOVED,
|
|
390
|
+
payload: { playerId },
|
|
391
|
+
} as InternalAction<S>);
|
|
392
|
+
},
|
|
393
|
+
);
|
|
491
394
|
});
|
|
492
395
|
|
|
493
396
|
server.on("error", (error) => {
|
|
@@ -498,50 +401,42 @@ export function GameHostProvider<S extends IGameState, A extends IAction>({
|
|
|
498
401
|
|
|
499
402
|
return () => {
|
|
500
403
|
server.stop();
|
|
501
|
-
|
|
502
|
-
clearTimeout(timer);
|
|
503
|
-
}
|
|
504
|
-
cleanupTimers.current.clear();
|
|
404
|
+
sessionManager.current.clearRemovalTimers();
|
|
505
405
|
};
|
|
506
406
|
}, []); // Run once on mount
|
|
507
407
|
|
|
508
408
|
// 3. Throttled State Broadcasts (~30fps)
|
|
509
409
|
// Batches rapid state changes so at most one broadcast is sent per ~33ms frame,
|
|
510
410
|
// reducing serialization overhead and network traffic for fast-updating games.
|
|
511
|
-
const
|
|
411
|
+
const broadcastScheduler = useRef(
|
|
412
|
+
new BroadcastScheduler({
|
|
413
|
+
stateThrottleMs: config.stateThrottleMs,
|
|
414
|
+
}),
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
useEffect(() => {
|
|
418
|
+
broadcastScheduler.current.setStateThrottleMs(
|
|
419
|
+
config.stateThrottleMs ?? DEFAULT_STATE_THROTTLE_MS,
|
|
420
|
+
);
|
|
421
|
+
}, [config.stateThrottleMs]);
|
|
512
422
|
|
|
513
423
|
const broadcastState = useCallback(() => {
|
|
514
424
|
if (wsServer.current) {
|
|
515
425
|
const actions = actionQueue.current;
|
|
516
426
|
actionQueue.current = [];
|
|
517
|
-
wsServer.current.broadcast(
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
newState: stateRef.current,
|
|
521
|
-
timestamp: Date.now(),
|
|
522
|
-
...(actions.length === 1
|
|
523
|
-
? { action: actions[0] }
|
|
524
|
-
: actions.length > 1
|
|
525
|
-
? { action: actions }
|
|
526
|
-
: {}),
|
|
527
|
-
},
|
|
528
|
-
});
|
|
427
|
+
wsServer.current.broadcast(
|
|
428
|
+
createStateUpdateMessage(stateRef.current, actions),
|
|
429
|
+
);
|
|
529
430
|
}
|
|
530
431
|
}, []);
|
|
531
432
|
|
|
532
433
|
useEffect(() => {
|
|
533
434
|
// Cancel any pending broadcast and schedule a fresh one.
|
|
534
435
|
// This ensures the broadcast always uses the latest stateRef.
|
|
535
|
-
|
|
536
|
-
clearTimeout(broadcastTimer.current);
|
|
537
|
-
}
|
|
538
|
-
broadcastTimer.current = setTimeout(broadcastState, 33); // ~30fps
|
|
436
|
+
broadcastScheduler.current.schedule(broadcastState);
|
|
539
437
|
|
|
540
438
|
return () => {
|
|
541
|
-
|
|
542
|
-
clearTimeout(broadcastTimer.current);
|
|
543
|
-
broadcastTimer.current = null;
|
|
544
|
-
}
|
|
439
|
+
broadcastScheduler.current.cancel();
|
|
545
440
|
};
|
|
546
441
|
}, [state, broadcastState]);
|
|
547
442
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** Maximum actions per rate-limit window. */
|
|
2
|
+
export const RATE_LIMIT_MAX = 60;
|
|
3
|
+
|
|
4
|
+
/** Rate-limit window duration (ms). */
|
|
5
|
+
export const RATE_LIMIT_WINDOW = 1000;
|
|
6
|
+
|
|
7
|
+
export interface RateLimitInfo {
|
|
8
|
+
count: number;
|
|
9
|
+
windowStart: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface RateLimitResult extends RateLimitInfo {
|
|
13
|
+
allowed: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ActionRateLimiterOptions {
|
|
17
|
+
maxActions?: number;
|
|
18
|
+
windowMs?: number;
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Per-socket action limiter that preserves the host provider's original
|
|
24
|
+
* fixed-window algorithm.
|
|
25
|
+
*/
|
|
26
|
+
export class ActionRateLimiter {
|
|
27
|
+
private readonly maxActions: number;
|
|
28
|
+
private readonly windowMs: number;
|
|
29
|
+
private readonly now: () => number;
|
|
30
|
+
private readonly limits = new Map<string, RateLimitInfo>();
|
|
31
|
+
|
|
32
|
+
constructor(options: ActionRateLimiterOptions = {}) {
|
|
33
|
+
this.maxActions = options.maxActions ?? RATE_LIMIT_MAX;
|
|
34
|
+
this.windowMs = options.windowMs ?? RATE_LIMIT_WINDOW;
|
|
35
|
+
this.now = options.now ?? Date.now;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
record(socketId: string): RateLimitResult {
|
|
39
|
+
const now = this.now();
|
|
40
|
+
let rateInfo = this.limits.get(socketId);
|
|
41
|
+
|
|
42
|
+
if (!rateInfo || now - rateInfo.windowStart > this.windowMs) {
|
|
43
|
+
rateInfo = { count: 0, windowStart: now };
|
|
44
|
+
this.limits.set(socketId, rateInfo);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
rateInfo.count++;
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
...rateInfo,
|
|
51
|
+
allowed: rateInfo.count <= this.maxActions,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
reset(socketId: string): void {
|
|
56
|
+
this.limits.delete(socketId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
clear(): void {
|
|
60
|
+
this.limits.clear();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
get(socketId: string): RateLimitInfo | undefined {
|
|
64
|
+
return this.limits.get(socketId);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_DISCONNECT_TIMEOUT,
|
|
3
|
+
InternalActionTypes,
|
|
4
|
+
derivePlayerId,
|
|
5
|
+
derivePlayerIdLegacy,
|
|
6
|
+
type IGameState,
|
|
7
|
+
type InternalAction,
|
|
8
|
+
} from "@couch-kit/core";
|
|
9
|
+
|
|
10
|
+
export interface SessionTimerScheduler<TTimer> {
|
|
11
|
+
setTimeout(callback: () => void, delayMs: number): TTimer;
|
|
12
|
+
clearTimeout(timer: TTimer): void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const defaultSessionTimerScheduler: SessionTimerScheduler<
|
|
16
|
+
ReturnType<typeof setTimeout>
|
|
17
|
+
> = {
|
|
18
|
+
setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
|
|
19
|
+
clearTimeout: (timer) => clearTimeout(timer),
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface JoinSessionPayload {
|
|
23
|
+
name: string;
|
|
24
|
+
avatar?: string;
|
|
25
|
+
secret: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface HostSessionManagerOptions<TTimer> {
|
|
29
|
+
disconnectTimeout?: number;
|
|
30
|
+
getDisconnectTimeout?: () => number;
|
|
31
|
+
scheduler?: SessionTimerScheduler<TTimer>;
|
|
32
|
+
derivePlayerId?: (secret: string) => Promise<string>;
|
|
33
|
+
derivePlayerIdLegacy?: (secret: string) => string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type JoinSessionResult<S extends IGameState> = {
|
|
37
|
+
playerId: string;
|
|
38
|
+
socketId: string;
|
|
39
|
+
secret: string;
|
|
40
|
+
isReconnect: boolean;
|
|
41
|
+
action: InternalAction<S>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type DisconnectSessionResult<S extends IGameState> =
|
|
45
|
+
| { kind: "unknown" }
|
|
46
|
+
| { kind: "stale"; playerId: string; secret: string }
|
|
47
|
+
| {
|
|
48
|
+
kind: "left";
|
|
49
|
+
playerId: string;
|
|
50
|
+
secret: string;
|
|
51
|
+
action: InternalAction<S>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
type PlayersSource<S extends IGameState> =
|
|
55
|
+
| S["players"]
|
|
56
|
+
| (() => S["players"]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Tracks host player sessions by secret and socket ID, including reconnects and
|
|
60
|
+
* delayed cleanup of disconnected players.
|
|
61
|
+
*/
|
|
62
|
+
export class HostSessionManager<
|
|
63
|
+
TTimer = ReturnType<typeof setTimeout>,
|
|
64
|
+
> {
|
|
65
|
+
private readonly sessions = new Map<string, string>();
|
|
66
|
+
private readonly reverseMap = new Map<string, string>();
|
|
67
|
+
private readonly cleanupTimers = new Map<string, TTimer>();
|
|
68
|
+
private readonly socketIdToPlayerId = new Map<string, string>();
|
|
69
|
+
private readonly scheduler: SessionTimerScheduler<TTimer>;
|
|
70
|
+
private readonly getDisconnectTimeout: () => number;
|
|
71
|
+
private readonly derivePlayerIdFn: (secret: string) => Promise<string>;
|
|
72
|
+
private readonly derivePlayerIdLegacyFn: (secret: string) => string;
|
|
73
|
+
|
|
74
|
+
constructor(options: HostSessionManagerOptions<TTimer> = {}) {
|
|
75
|
+
this.scheduler =
|
|
76
|
+
options.scheduler ??
|
|
77
|
+
(defaultSessionTimerScheduler as unknown as SessionTimerScheduler<TTimer>);
|
|
78
|
+
this.getDisconnectTimeout =
|
|
79
|
+
options.getDisconnectTimeout ??
|
|
80
|
+
(() => options.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT);
|
|
81
|
+
this.derivePlayerIdFn = options.derivePlayerId ?? derivePlayerId;
|
|
82
|
+
this.derivePlayerIdLegacyFn =
|
|
83
|
+
options.derivePlayerIdLegacy ?? derivePlayerIdLegacy;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async handleJoin<S extends IGameState>(
|
|
87
|
+
socketId: string,
|
|
88
|
+
payload: JoinSessionPayload,
|
|
89
|
+
playersSource: PlayersSource<S>,
|
|
90
|
+
): Promise<JoinSessionResult<S>> {
|
|
91
|
+
const hashedId = await this.derivePlayerIdFn(payload.secret);
|
|
92
|
+
const players =
|
|
93
|
+
typeof playersSource === "function" ? playersSource() : playersSource;
|
|
94
|
+
let playerId = hashedId;
|
|
95
|
+
|
|
96
|
+
const legacyId = this.derivePlayerIdLegacyFn(payload.secret);
|
|
97
|
+
if (!players[playerId] && players[legacyId]) {
|
|
98
|
+
playerId = legacyId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
this.socketIdToPlayerId.set(socketId, playerId);
|
|
102
|
+
this.sessions.set(payload.secret, socketId);
|
|
103
|
+
this.reverseMap.set(socketId, payload.secret);
|
|
104
|
+
this.cancelRemoval(playerId);
|
|
105
|
+
|
|
106
|
+
const isReconnect = !!players[playerId];
|
|
107
|
+
const action = isReconnect
|
|
108
|
+
? ({
|
|
109
|
+
type: InternalActionTypes.PLAYER_RECONNECTED,
|
|
110
|
+
payload: { playerId },
|
|
111
|
+
} as InternalAction<S>)
|
|
112
|
+
: ({
|
|
113
|
+
type: InternalActionTypes.PLAYER_JOINED,
|
|
114
|
+
payload: { id: playerId, name: payload.name, avatar: payload.avatar },
|
|
115
|
+
} as InternalAction<S>);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
playerId,
|
|
119
|
+
socketId,
|
|
120
|
+
secret: payload.secret,
|
|
121
|
+
isReconnect,
|
|
122
|
+
action,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
handleDisconnect<S extends IGameState>(
|
|
127
|
+
socketId: string,
|
|
128
|
+
): DisconnectSessionResult<S> {
|
|
129
|
+
const playerId = this.socketIdToPlayerId.get(socketId);
|
|
130
|
+
this.socketIdToPlayerId.delete(socketId);
|
|
131
|
+
|
|
132
|
+
const secret = this.reverseMap.get(socketId);
|
|
133
|
+
this.reverseMap.delete(socketId);
|
|
134
|
+
|
|
135
|
+
if (!playerId || !secret) return { kind: "unknown" };
|
|
136
|
+
|
|
137
|
+
if (this.sessions.get(secret) !== socketId) {
|
|
138
|
+
return { kind: "stale", playerId, secret };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
kind: "left",
|
|
143
|
+
playerId,
|
|
144
|
+
secret,
|
|
145
|
+
action: {
|
|
146
|
+
type: InternalActionTypes.PLAYER_LEFT,
|
|
147
|
+
payload: { playerId },
|
|
148
|
+
} as InternalAction<S>,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
scheduleRemoval(
|
|
153
|
+
playerId: string,
|
|
154
|
+
secret: string,
|
|
155
|
+
onRemove: (playerId: string) => void,
|
|
156
|
+
): void {
|
|
157
|
+
const timer = this.scheduler.setTimeout(() => {
|
|
158
|
+
this.cleanupTimers.delete(playerId);
|
|
159
|
+
this.sessions.delete(secret);
|
|
160
|
+
onRemove(playerId);
|
|
161
|
+
}, this.getDisconnectTimeout());
|
|
162
|
+
|
|
163
|
+
this.cleanupTimers.set(playerId, timer);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
cancelRemoval(playerId: string): void {
|
|
167
|
+
const existingTimer = this.cleanupTimers.get(playerId);
|
|
168
|
+
if (existingTimer) {
|
|
169
|
+
this.scheduler.clearTimeout(existingTimer);
|
|
170
|
+
this.cleanupTimers.delete(playerId);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
clearRemovalTimers(): void {
|
|
175
|
+
for (const timer of this.cleanupTimers.values()) {
|
|
176
|
+
this.scheduler.clearTimeout(timer);
|
|
177
|
+
}
|
|
178
|
+
this.cleanupTimers.clear();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
getPlayerIdForSocket(socketId: string): string | undefined {
|
|
182
|
+
return this.socketIdToPlayerId.get(socketId);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
getSocketIdForSecret(secret: string): string | undefined {
|
|
186
|
+
return this.sessions.get(secret);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
hasPendingRemoval(playerId: string): boolean {
|
|
190
|
+
return this.cleanupTimers.has(playerId);
|
|
191
|
+
}
|
|
192
|
+
}
|