@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/CHANGELOG.md +76 -0
- package/README.md +2 -2
- package/dist/index.js +186 -43
- package/package.json +26 -2
- package/src/assets.ts +78 -21
- package/src/client.ts +116 -44
- package/src/time-sync.ts +14 -4
- package/tsconfig.tsbuildinfo +1 -1
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
|
-
|
|
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<
|
|
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
|
|
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 =
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
113
|
+
secret = generateId();
|
|
91
114
|
}
|
|
92
115
|
|
|
93
116
|
// Join with secret
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
167
|
+
configRef.current.onDisconnect?.();
|
|
138
168
|
|
|
139
|
-
//
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
177
|
+
baseDelay * Math.pow(2, reconnectAttempts.current),
|
|
178
|
+
maxDelay,
|
|
144
179
|
);
|
|
145
180
|
reconnectAttempts.current++;
|
|
146
181
|
|
|
147
|
-
if (
|
|
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 (
|
|
192
|
+
if (configRef.current.debug) console.error("[GameClient] Error", e);
|
|
158
193
|
setStatus("error");
|
|
159
194
|
};
|
|
160
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
const interval = setInterval(sync, 5000);
|
|
87
|
+
const interval = setInterval(sync, DEFAULT_SYNC_INTERVAL);
|
|
78
88
|
return () => clearInterval(interval);
|
|
79
89
|
}, [socket]);
|
|
80
90
|
|