@couch-kit/client 0.3.0 → 0.4.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/src/client.ts CHANGED
@@ -1,30 +1,38 @@
1
1
  import { useState, useEffect, useRef, useCallback, useReducer } from "react";
2
2
  import {
3
3
  MessageTypes,
4
+ InternalActionTypes,
5
+ DEFAULT_WS_PORT_OFFSET,
6
+ DEFAULT_MAX_RETRIES,
7
+ DEFAULT_BASE_DELAY,
8
+ DEFAULT_MAX_DELAY,
9
+ generateId,
10
+ createGameReducer,
4
11
  type HostMessage,
5
12
  type IGameState,
6
13
  type IAction,
14
+ type InternalAction,
7
15
  } from "@couch-kit/core";
8
16
  import { useServerTime } from "./time-sync";
9
17
 
10
- // Reconnection Constants
11
- const MAX_RETRIES = 5;
12
- const BASE_DELAY = 1000;
13
-
14
- interface ClientConfig<S extends IGameState, A extends IAction> {
18
+ export interface ClientConfig<S extends IGameState, A extends IAction> {
15
19
  url?: string; // Full WebSocket URL (overrides auto-detection)
16
20
  wsPort?: number; // WebSocket port (default: auto-detected as HTTP port + 2)
17
- reducer: (state: S, action: A) => S;
21
+ reducer: (state: S, action: A | InternalAction<S>) => S;
18
22
  initialState: S;
19
23
  name?: string; // Player display name (default: "Player")
20
- avatar?: string; // Player avatar emoji (default: "😀")
24
+ avatar?: string; // Player avatar emoji (default: "\u{1F600}")
25
+ /** Maximum reconnection attempts before giving up (default: 5). */
26
+ maxRetries?: number;
27
+ /** Base delay (ms) for exponential backoff reconnection (default: 1000). */
28
+ baseDelay?: number;
29
+ /** Maximum delay (ms) cap for reconnection backoff (default: 10000). */
30
+ maxDelay?: number;
21
31
  onConnect?: () => void;
22
32
  onDisconnect?: () => void;
23
33
  debug?: boolean;
24
34
  }
25
35
 
26
- import { createGameReducer } from "@couch-kit/core";
27
-
28
36
  export function useGameClient<S extends IGameState, A extends IAction>(
29
37
  config: ClientConfig<S, A>,
30
38
  ) {
@@ -42,65 +50,85 @@ export function useGameClient<S extends IGameState, A extends IAction>(
42
50
 
43
51
  const socketRef = useRef<WebSocket | null>(null);
44
52
  const reconnectAttempts = useRef(0);
45
- const reconnectTimer = useRef<Timer | null>(null);
53
+ const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
54
+ const intentionalClose = useRef(false);
55
+
56
+ // Keep refs for values used inside connect() to avoid stale closures
57
+ const configRef = useRef(config);
58
+ useEffect(() => {
59
+ configRef.current = config;
60
+ });
46
61
 
47
62
  // Time Sync Hook
48
- const { getServerTime, handlePong } = useServerTime(socketRef.current);
63
+ const { getServerTime, rtt, handlePong } = useServerTime(socketRef.current);
64
+
65
+ const maxRetries = config.maxRetries ?? DEFAULT_MAX_RETRIES;
66
+ const baseDelay = config.baseDelay ?? DEFAULT_BASE_DELAY;
67
+ const maxDelay = config.maxDelay ?? DEFAULT_MAX_DELAY;
49
68
 
50
69
  const connect = useCallback(() => {
70
+ const cfg = configRef.current;
71
+ intentionalClose.current = false;
72
+
51
73
  // 1. Magic Client: Determine URL
52
74
  // If explicit URL provided, use it.
53
75
  // Otherwise, assume we are being served by the Host's static server,
54
76
  // so derive the WebSocket URL from window.location.
55
- // Convention: WS port = HTTP port + 2 (e.g., HTTP 8080 WS 8082)
77
+ // Convention: WS port = HTTP port + 2 (e.g., HTTP 8080 -> WS 8082)
56
78
  // Port + 1 is skipped to avoid conflicts with Metro bundler (which uses 8081)
57
- let wsUrl = config.url;
79
+ let wsUrl = cfg.url;
58
80
 
59
81
  if (!wsUrl && typeof window !== "undefined") {
60
82
  const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
61
83
  const host = window.location.hostname;
62
84
  const httpPort = parseInt(window.location.port, 10) || 80;
63
- const wsPort = config.wsPort || httpPort + 2;
85
+ const wsPort = cfg.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
64
86
  wsUrl = `${protocol}//${host}:${wsPort}`;
65
87
  }
66
88
 
67
89
  if (!wsUrl) return;
68
90
 
69
- if (config.debug) console.log(`[GameClient] Connecting to ${wsUrl}`);
91
+ if (cfg.debug) console.log(`[GameClient] Connecting to ${wsUrl}`);
70
92
  setStatus("connecting");
71
93
 
72
94
  const ws = new WebSocket(wsUrl);
73
95
  socketRef.current = ws;
74
96
 
75
97
  ws.onopen = () => {
98
+ const currentCfg = configRef.current;
76
99
  setStatus("connected");
77
100
  reconnectAttempts.current = 0;
78
- config.onConnect?.();
101
+ currentCfg.onConnect?.();
79
102
 
80
- // Session Recovery Logic
103
+ // Session Recovery Logic -- use cryptographically random secrets
81
104
  let secret: string | null = null;
82
105
  try {
83
106
  secret = localStorage.getItem("ck_secret");
84
107
  if (!secret) {
85
- secret = Math.random().toString(36).substring(2, 15);
108
+ secret = generateId();
86
109
  localStorage.setItem("ck_secret", secret);
87
110
  }
88
111
  } catch {
89
112
  // localStorage unavailable (Safari private browsing, restrictive WebViews, etc.)
90
- secret = Math.random().toString(36).substring(2, 15);
113
+ secret = generateId();
91
114
  }
92
115
 
93
116
  // Join with secret
94
- ws.send(
95
- JSON.stringify({
96
- type: MessageTypes.JOIN,
97
- payload: {
98
- name: config.name || "Player",
99
- avatar: config.avatar || "\u{1F600}",
100
- secret,
101
- },
102
- }),
103
- );
117
+ try {
118
+ ws.send(
119
+ JSON.stringify({
120
+ type: MessageTypes.JOIN,
121
+ payload: {
122
+ name: currentCfg.name || "Player",
123
+ avatar: currentCfg.avatar || "\u{1F600}",
124
+ secret,
125
+ },
126
+ }),
127
+ );
128
+ } catch (e) {
129
+ if (currentCfg.debug)
130
+ console.error("[GameClient] Failed to send JOIN:", e);
131
+ }
104
132
  };
105
133
 
106
134
  ws.onmessage = (event) => {
@@ -111,16 +139,18 @@ export function useGameClient<S extends IGameState, A extends IAction>(
111
139
  case MessageTypes.WELCOME:
112
140
  setPlayerId(msg.payload.playerId);
113
141
  // Hydrate state from server (Single Source of Truth)
114
- // The WELCOME payload contains the full authoritative game state.
115
- // Dispatch HYDRATE which is handled by createGameReducer in @couch-kit/core.
116
- // @ts-expect-error - HYDRATE is an internal action managed by createGameReducer
117
- dispatchLocal({ type: "HYDRATE", payload: msg.payload.state });
142
+ dispatchLocal({
143
+ type: InternalActionTypes.HYDRATE,
144
+ payload: msg.payload.state as S,
145
+ } as InternalAction<S>);
118
146
  break;
119
147
 
120
148
  case MessageTypes.STATE_UPDATE:
121
149
  // Full state replacement from the host's authoritative state.
122
- // @ts-expect-error - HYDRATE is an internal action managed by createGameReducer
123
- dispatchLocal({ type: "HYDRATE", payload: msg.payload.newState });
150
+ dispatchLocal({
151
+ type: InternalActionTypes.HYDRATE,
152
+ payload: msg.payload.newState as S,
153
+ } as InternalAction<S>);
124
154
  break;
125
155
 
126
156
  case MessageTypes.PONG:
@@ -132,19 +162,24 @@ export function useGameClient<S extends IGameState, A extends IAction>(
132
162
  }
133
163
  };
134
164
 
135
- ws.onclose = () => {
165
+ ws.onclose = (event) => {
136
166
  setStatus("disconnected");
137
- config.onDisconnect?.();
167
+ configRef.current.onDisconnect?.();
138
168
 
139
- // Aggressive Reconnection Logic
140
- if (reconnectAttempts.current < MAX_RETRIES) {
169
+ // Don't reconnect if the close was intentional or if the server
170
+ // sent a policy/unexpected error close code
171
+ if (intentionalClose.current) return;
172
+ if (event.code === 1008 || event.code === 1011) return;
173
+
174
+ // Exponential backoff reconnection
175
+ if (reconnectAttempts.current < maxRetries) {
141
176
  const delay = Math.min(
142
- BASE_DELAY * Math.pow(2, reconnectAttempts.current),
143
- 10000,
177
+ baseDelay * Math.pow(2, reconnectAttempts.current),
178
+ maxDelay,
144
179
  );
145
180
  reconnectAttempts.current++;
146
181
 
147
- if (config.debug)
182
+ if (configRef.current.debug)
148
183
  console.log(`[GameClient] Reconnecting in ${delay}ms...`);
149
184
 
150
185
  reconnectTimer.current = setTimeout(() => {
@@ -154,20 +189,51 @@ export function useGameClient<S extends IGameState, A extends IAction>(
154
189
  };
155
190
 
156
191
  ws.onerror = (e) => {
157
- if (config.debug) console.error("[GameClient] Error", e);
192
+ if (configRef.current.debug) console.error("[GameClient] Error", e);
158
193
  setStatus("error");
159
194
  };
160
- }, [config.url, config.wsPort, config.debug]);
195
+ // Only re-create the connect function when URL/port actually changes.
196
+ // Config values like name, avatar, callbacks are read from configRef.
197
+ }, [config.url, config.wsPort, maxRetries, baseDelay, maxDelay]);
161
198
 
162
199
  // Initial Connection
163
200
  useEffect(() => {
164
201
  connect();
165
202
  return () => {
203
+ intentionalClose.current = true;
166
204
  if (socketRef.current) socketRef.current.close();
167
205
  if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
168
206
  };
169
207
  }, [connect]);
170
208
 
209
+ /**
210
+ * Manually disconnect from the host.
211
+ * Prevents automatic reconnection.
212
+ */
213
+ const disconnect = useCallback(() => {
214
+ intentionalClose.current = true;
215
+ if (reconnectTimer.current) {
216
+ clearTimeout(reconnectTimer.current);
217
+ reconnectTimer.current = null;
218
+ }
219
+ if (socketRef.current) {
220
+ socketRef.current.close();
221
+ socketRef.current = null;
222
+ }
223
+ setStatus("disconnected");
224
+ }, []);
225
+
226
+ /**
227
+ * Manually reconnect to the host.
228
+ * Resets the reconnection attempt counter.
229
+ */
230
+ const reconnect = useCallback(() => {
231
+ disconnect();
232
+ reconnectAttempts.current = 0;
233
+ // Small delay to let the close complete
234
+ setTimeout(() => connect(), 50);
235
+ }, [disconnect, connect]);
236
+
171
237
  // Action Dispatcher
172
238
  const sendAction = useCallback((action: A) => {
173
239
  // 1. Optimistic Update
@@ -190,5 +256,11 @@ export function useGameClient<S extends IGameState, A extends IAction>(
190
256
  playerId,
191
257
  sendAction,
192
258
  getServerTime,
259
+ /** Round-trip time (ms) to the server. Updated periodically via PING/PONG. */
260
+ rtt,
261
+ /** Manually disconnect from the host. Prevents automatic reconnection. */
262
+ disconnect,
263
+ /** Manually reconnect to the host. Resets the reconnection attempt counter. */
264
+ reconnect,
193
265
  };
194
266
  }
package/src/time-sync.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { useState, useEffect, useRef, useCallback } from "react";
2
- import { MessageTypes } from "@couch-kit/core";
2
+ import {
3
+ MessageTypes,
4
+ generateId,
5
+ DEFAULT_SYNC_INTERVAL,
6
+ MAX_PENDING_PINGS,
7
+ } from "@couch-kit/core";
3
8
 
4
9
  interface TimeSyncState {
5
10
  offset: number; // Difference between server time and local time
@@ -58,7 +63,13 @@ export function useServerTime(socket: WebSocket | null) {
58
63
  if (!socket || socket.readyState !== WebSocket.OPEN) return;
59
64
 
60
65
  const sync = () => {
61
- const id = Math.random().toString(36).substring(7);
66
+ // Prevent unbounded growth if PONGs are lost
67
+ if (pings.current.size >= MAX_PENDING_PINGS) {
68
+ const oldest = pings.current.keys().next().value;
69
+ if (oldest !== undefined) pings.current.delete(oldest);
70
+ }
71
+
72
+ const id = generateId();
62
73
  const timestamp = Date.now();
63
74
  pings.current.set(id, timestamp);
64
75
 
@@ -73,8 +84,7 @@ export function useServerTime(socket: WebSocket | null) {
73
84
  // Initial sync
74
85
  sync();
75
86
 
76
- // Sync every 5 seconds
77
- const interval = setInterval(sync, 5000);
87
+ const interval = setInterval(sync, DEFAULT_SYNC_INTERVAL);
78
88
  return () => clearInterval(interval);
79
89
  }, [socket]);
80
90