@couch-kit/host 1.7.9 → 1.7.10

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.
@@ -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
@@ -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. */
@@ -1 +1 @@
1
- {"version":3,"file":"provider.d.ts","sourceRoot":"","sources":["../src/provider.tsx"],"names":[],"mappings":"AAAA,OAAO,KAQN,MAAM,OAAO,CAAC;AAGf,OAAO,EAUL,KAAK,UAAU,EACf,KAAK,OAAO,EAGb,MAAM,iBAAiB,CAAC;AAQzB,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,+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,qBAobA;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,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.9",
3
+ "version": "1.7.10",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
@@ -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
- /** Maximum actions per rate-limit window. */
29
- const RATE_LIMIT_MAX = 60;
30
-
31
- /** Rate-limit window duration (ms). */
32
- const RATE_LIMIT_WINDOW = 1000;
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
- // Track active sessions: secret -> socketId
197
- const sessions = useRef<Map<string, string>>(new Map());
198
-
199
- // Reverse lookup: socketId -> secret (for disconnect resolution)
200
- const reverseMap = useRef<Map<string, string>>(new Map());
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
- // Rate limiting: track action count per socket
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 (!secret || !isValidSecret(secret)) {
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
- // Async dual-derivation: try SHA-256 first, fall back to legacy
289
- derivePlayerId(secret).then((hashedId) => {
290
- let playerId = hashedId;
291
-
292
- // Migration: if a player exists under the legacy ID, use that instead
293
- const legacyId = derivePlayerIdLegacy(secret);
294
- if (
295
- !stateRef.current.players[playerId] &&
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
- // Queue RECONNECTED message
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: false,
265
+ isReconnect,
346
266
  });
347
- }
348
267
 
349
- configRef.current.onPlayerJoined?.(playerId, payload.name);
350
- }).catch((err) => {
351
- if (configRef.current.debug) {
352
- console.error("[GameHost] Failed to derive player ID:", err);
353
- }
354
- server.send(socketId, {
355
- type: MessageTypes.ERROR,
356
- payload: {
357
- code: "JOIN_FAILED",
358
- message: "Failed to process join request",
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
- const now = Date.now();
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 = socketIdToPlayerId.current.get(socketId);
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 = socketIdToPlayerId.current.get(socketId);
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
- rateLimits.current.delete(socketId);
365
+ rateLimiter.current.reset(socketId);
459
366
 
460
- if (!playerId || !secret) return; // Unknown socket, nothing to do
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
- // RACE GUARD: Only mark as left if this socket is still the active one for this secret
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
- const timeout =
481
- configRef.current.disconnectTimeout ?? DEFAULT_DISCONNECT_TIMEOUT;
482
- const timer = setTimeout(() => {
483
- cleanupTimers.current.delete(playerId);
484
- sessions.current.delete(secret);
485
- dispatch({
486
- type: InternalActionTypes.PLAYER_REMOVED,
487
- payload: { playerId },
488
- } as InternalAction<S>);
489
- }, timeout);
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
- for (const timer of cleanupTimers.current.values()) {
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 broadcastTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
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
- type: MessageTypes.STATE_UPDATE,
519
- payload: {
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
- if (broadcastTimer.current) {
536
- clearTimeout(broadcastTimer.current);
537
- }
538
- broadcastTimer.current = setTimeout(broadcastState, 33); // ~30fps
436
+ broadcastScheduler.current.schedule(broadcastState);
539
437
 
540
438
  return () => {
541
- if (broadcastTimer.current) {
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
+ }