@couch-kit/client 0.8.5 → 0.8.7
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 +15 -0
- package/dist/index.js +110 -61
- package/lib/client.d.ts.map +1 -1
- package/lib/connection.d.ts +98 -0
- package/lib/connection.d.ts.map +1 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client.ts +49 -63
- package/src/connection.ts +172 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @couch-kit/client
|
|
2
2
|
|
|
3
|
+
## 0.8.7
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- [#70](https://github.com/faluciano/react-native-couch-kit/pull/70) [`7897e88`](https://github.com/faluciano/react-native-couch-kit/commit/7897e887967c440b5b973c8dc753bf1fa705b993) Thanks [@faluciano](https://github.com/faluciano)! - Extract the client's connection logic (WebSocket URL resolution, reconnect
|
|
8
|
+
backoff, session-secret recovery, and host-message interpretation) into a
|
|
9
|
+
framework-free `connection` module with full unit test coverage. No behavior
|
|
10
|
+
change; these pure helpers are now exported for reuse and testing.
|
|
11
|
+
|
|
12
|
+
## 0.8.6
|
|
13
|
+
|
|
14
|
+
### Patch Changes
|
|
15
|
+
|
|
16
|
+
- [#62](https://github.com/faluciano/react-native-couch-kit/pull/62) [`bd9cd2d`](https://github.com/faluciano/react-native-couch-kit/commit/bd9cd2d6eebc464e33fd1ecbbb6731783ba3ed0c) Thanks [@faluciano](https://github.com/faluciano)! - Simplify session-secret recovery in the game client to remove a redundant variable initializer. Behavior is unchanged: an existing `ck_secret` is reused, a new one is generated and persisted when absent, and a fresh secret is generated when `localStorage` is unavailable.
|
|
17
|
+
|
|
3
18
|
## 0.8.5
|
|
4
19
|
|
|
5
20
|
### Patch Changes
|
package/dist/index.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
// src/client.ts
|
|
2
2
|
import { useState as useState2, useEffect as useEffect2, useRef as useRef2, useCallback as useCallback2, useReducer } from "react";
|
|
3
3
|
import {
|
|
4
|
-
MessageTypes as
|
|
4
|
+
MessageTypes as MessageTypes3,
|
|
5
5
|
InternalActionTypes,
|
|
6
|
-
DEFAULT_WS_PORT_OFFSET,
|
|
7
|
-
DEFAULT_WS_PATH,
|
|
8
6
|
DEFAULT_MAX_RETRIES,
|
|
9
7
|
DEFAULT_BASE_DELAY,
|
|
10
8
|
DEFAULT_MAX_DELAY,
|
|
11
|
-
generateId as generateId2,
|
|
12
9
|
createGameReducer
|
|
13
10
|
} from "@couch-kit/core";
|
|
14
11
|
|
|
@@ -69,6 +66,74 @@ function useServerTime(socket) {
|
|
|
69
66
|
return { getServerTime, rtt: timeSync.rtt, handlePong };
|
|
70
67
|
}
|
|
71
68
|
|
|
69
|
+
// src/connection.ts
|
|
70
|
+
import {
|
|
71
|
+
MessageTypes as MessageTypes2,
|
|
72
|
+
DEFAULT_WS_PORT_OFFSET,
|
|
73
|
+
DEFAULT_WS_PATH,
|
|
74
|
+
generateId as generateId2
|
|
75
|
+
} from "@couch-kit/core";
|
|
76
|
+
var SESSION_SECRET_KEY = "ck_secret";
|
|
77
|
+
var NON_RECOVERABLE_CLOSE_CODES = new Set([
|
|
78
|
+
1008,
|
|
79
|
+
1011
|
|
80
|
+
]);
|
|
81
|
+
function resolveWebSocketUrl(config, location) {
|
|
82
|
+
if (config.url)
|
|
83
|
+
return config.url;
|
|
84
|
+
if (!location)
|
|
85
|
+
return null;
|
|
86
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
87
|
+
const host = location.hostname;
|
|
88
|
+
const httpPort = parseInt(location.port, 10) || 80;
|
|
89
|
+
const wsPort = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
90
|
+
return `${protocol}//${host}:${wsPort}${DEFAULT_WS_PATH}`;
|
|
91
|
+
}
|
|
92
|
+
function computeBackoffDelay(attempt, baseDelay, maxDelay) {
|
|
93
|
+
return Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
|
|
94
|
+
}
|
|
95
|
+
function shouldReconnect(decision) {
|
|
96
|
+
if (decision.intentionalClose)
|
|
97
|
+
return false;
|
|
98
|
+
if (NON_RECOVERABLE_CLOSE_CODES.has(decision.closeCode))
|
|
99
|
+
return false;
|
|
100
|
+
return decision.attempts < decision.maxRetries;
|
|
101
|
+
}
|
|
102
|
+
function resolveSessionSecret(storage, generate = generateId2) {
|
|
103
|
+
try {
|
|
104
|
+
if (!storage)
|
|
105
|
+
return generate();
|
|
106
|
+
const stored = storage.getItem(SESSION_SECRET_KEY);
|
|
107
|
+
if (stored)
|
|
108
|
+
return stored;
|
|
109
|
+
const secret = generate();
|
|
110
|
+
storage.setItem(SESSION_SECRET_KEY, secret);
|
|
111
|
+
return secret;
|
|
112
|
+
} catch {
|
|
113
|
+
return generate();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
function interpretHostMessage(msg) {
|
|
117
|
+
switch (msg.type) {
|
|
118
|
+
case MessageTypes2.WELCOME:
|
|
119
|
+
return [
|
|
120
|
+
{ kind: "setPlayerId", playerId: msg.payload.playerId },
|
|
121
|
+
{ kind: "hydrate", state: msg.payload.state }
|
|
122
|
+
];
|
|
123
|
+
case MessageTypes2.STATE_UPDATE:
|
|
124
|
+
return [{ kind: "hydrate", state: msg.payload.newState }];
|
|
125
|
+
case MessageTypes2.PONG:
|
|
126
|
+
return [{ kind: "pong", payload: msg.payload }];
|
|
127
|
+
case MessageTypes2.RECONNECTED:
|
|
128
|
+
return [
|
|
129
|
+
{ kind: "setPlayerId", playerId: msg.payload.playerId },
|
|
130
|
+
{ kind: "hydrate", state: msg.payload.state }
|
|
131
|
+
];
|
|
132
|
+
default:
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
72
137
|
// src/client.ts
|
|
73
138
|
function useGameClient(config) {
|
|
74
139
|
const [status, setStatus] = useState2("disconnected");
|
|
@@ -93,14 +158,7 @@ function useGameClient(config) {
|
|
|
93
158
|
const connect = useCallback2(() => {
|
|
94
159
|
const cfg = configRef.current;
|
|
95
160
|
intentionalClose.current = false;
|
|
96
|
-
|
|
97
|
-
if (!wsUrl && typeof window !== "undefined") {
|
|
98
|
-
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
99
|
-
const host = window.location.hostname;
|
|
100
|
-
const httpPort = parseInt(window.location.port, 10) || 80;
|
|
101
|
-
const wsPort = cfg.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
102
|
-
wsUrl = `${protocol}//${host}:${wsPort}${DEFAULT_WS_PATH}`;
|
|
103
|
-
}
|
|
161
|
+
const wsUrl = resolveWebSocketUrl({ url: cfg.url, wsPort: cfg.wsPort }, typeof window !== "undefined" ? window.location : null);
|
|
104
162
|
if (!wsUrl)
|
|
105
163
|
return;
|
|
106
164
|
if (cfg.debug)
|
|
@@ -113,19 +171,10 @@ function useGameClient(config) {
|
|
|
113
171
|
setStatus("connected");
|
|
114
172
|
reconnectAttempts.current = 0;
|
|
115
173
|
currentCfg.onConnect?.();
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
secret = localStorage.getItem("ck_secret");
|
|
119
|
-
if (!secret) {
|
|
120
|
-
secret = generateId2();
|
|
121
|
-
localStorage.setItem("ck_secret", secret);
|
|
122
|
-
}
|
|
123
|
-
} catch {
|
|
124
|
-
secret = generateId2();
|
|
125
|
-
}
|
|
174
|
+
const secret = resolveSessionSecret(typeof localStorage !== "undefined" ? localStorage : null);
|
|
126
175
|
try {
|
|
127
176
|
ws.send(JSON.stringify({
|
|
128
|
-
type:
|
|
177
|
+
type: MessageTypes3.JOIN,
|
|
129
178
|
payload: {
|
|
130
179
|
name: currentCfg.name || "Player",
|
|
131
180
|
avatar: currentCfg.avatar || "\uD83D\uDE00",
|
|
@@ -138,53 +187,47 @@ function useGameClient(config) {
|
|
|
138
187
|
}
|
|
139
188
|
};
|
|
140
189
|
ws.onmessage = (event) => {
|
|
190
|
+
let msg;
|
|
141
191
|
try {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
192
|
+
msg = JSON.parse(event.data);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error("Failed to parse message", e);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
for (const effect of interpretHostMessage(msg)) {
|
|
198
|
+
switch (effect.kind) {
|
|
199
|
+
case "setPlayerId":
|
|
200
|
+
setPlayerId(effect.playerId);
|
|
150
201
|
break;
|
|
151
|
-
case
|
|
202
|
+
case "hydrate":
|
|
152
203
|
dispatchLocal({
|
|
153
204
|
type: InternalActionTypes.HYDRATE,
|
|
154
|
-
payload:
|
|
205
|
+
payload: effect.state
|
|
155
206
|
});
|
|
156
207
|
break;
|
|
157
|
-
case
|
|
158
|
-
handlePongRef.current(
|
|
159
|
-
break;
|
|
160
|
-
case MessageTypes2.RECONNECTED:
|
|
161
|
-
setPlayerId(msg.payload.playerId);
|
|
162
|
-
dispatchLocal({
|
|
163
|
-
type: InternalActionTypes.HYDRATE,
|
|
164
|
-
payload: msg.payload.state
|
|
165
|
-
});
|
|
208
|
+
case "pong":
|
|
209
|
+
handlePongRef.current(effect.payload);
|
|
166
210
|
break;
|
|
167
211
|
}
|
|
168
|
-
} catch (e) {
|
|
169
|
-
console.error("Failed to parse message", e);
|
|
170
212
|
}
|
|
171
213
|
};
|
|
172
214
|
ws.onclose = (event) => {
|
|
173
215
|
setStatus("disconnected");
|
|
174
216
|
configRef.current.onDisconnect?.();
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
217
|
+
if (!shouldReconnect({
|
|
218
|
+
intentionalClose: intentionalClose.current,
|
|
219
|
+
closeCode: event.code,
|
|
220
|
+
attempts: reconnectAttempts.current,
|
|
221
|
+
maxRetries
|
|
222
|
+
}))
|
|
178
223
|
return;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}, delay);
|
|
187
|
-
}
|
|
224
|
+
const delay = computeBackoffDelay(reconnectAttempts.current, baseDelay, maxDelay);
|
|
225
|
+
reconnectAttempts.current++;
|
|
226
|
+
if (configRef.current.debug)
|
|
227
|
+
console.log(`[GameClient] Reconnecting in ${delay}ms...`);
|
|
228
|
+
reconnectTimer.current = setTimeout(() => {
|
|
229
|
+
connect();
|
|
230
|
+
}, delay);
|
|
188
231
|
};
|
|
189
232
|
ws.onerror = (e) => {
|
|
190
233
|
if (configRef.current.debug)
|
|
@@ -223,7 +266,7 @@ function useGameClient(config) {
|
|
|
223
266
|
dispatchLocal(action);
|
|
224
267
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
|
225
268
|
socketRef.current.send(JSON.stringify({
|
|
226
|
-
type:
|
|
269
|
+
type: MessageTypes3.ACTION,
|
|
227
270
|
payload: action
|
|
228
271
|
}));
|
|
229
272
|
}
|
|
@@ -241,7 +284,7 @@ function useGameClient(config) {
|
|
|
241
284
|
}
|
|
242
285
|
// src/assets.ts
|
|
243
286
|
import { useState as useState3, useEffect as useEffect3, useRef as useRef3 } from "react";
|
|
244
|
-
import { MessageTypes as
|
|
287
|
+
import { MessageTypes as MessageTypes4 } from "@couch-kit/core";
|
|
245
288
|
function arraysEqual(a, b) {
|
|
246
289
|
if (a.length !== b.length)
|
|
247
290
|
return false;
|
|
@@ -268,7 +311,7 @@ function usePreload(assets, sendMessage) {
|
|
|
268
311
|
setProgress(100);
|
|
269
312
|
setFailedAssets([]);
|
|
270
313
|
sendMessageRef.current?.({
|
|
271
|
-
type:
|
|
314
|
+
type: MessageTypes4.ASSETS_LOADED,
|
|
272
315
|
payload: true
|
|
273
316
|
});
|
|
274
317
|
return;
|
|
@@ -286,7 +329,7 @@ function usePreload(assets, sendMessage) {
|
|
|
286
329
|
setFailedAssets([...failed]);
|
|
287
330
|
setLoaded(true);
|
|
288
331
|
sendMessageRef.current?.({
|
|
289
|
-
type:
|
|
332
|
+
type: MessageTypes4.ASSETS_LOADED,
|
|
290
333
|
payload: true
|
|
291
334
|
});
|
|
292
335
|
}
|
|
@@ -378,5 +421,11 @@ export {
|
|
|
378
421
|
usePreload,
|
|
379
422
|
useGameClient,
|
|
380
423
|
useDebugPanel,
|
|
381
|
-
|
|
424
|
+
shouldReconnect,
|
|
425
|
+
resolveWebSocketUrl,
|
|
426
|
+
resolveSessionSecret,
|
|
427
|
+
interpretHostMessage,
|
|
428
|
+
computeBackoffDelay,
|
|
429
|
+
calculateTimeSync,
|
|
430
|
+
SESSION_SECRET_KEY
|
|
382
431
|
};
|
package/lib/client.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAQL,KAAK,UAAU,EACf,KAAK,OAAO,EAEb,MAAM,iBAAiB,CAAC;AAUzB,MAAM,WAAW,YAAY,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO;IACnE,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,EAAE,MAAM,EAAE,CAAC,KAAK,CAAC,CAAC;IACpC,YAAY,EAAE,CAAC,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,mEAAmE;IACnE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,4EAA4E;IAC5E,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,wEAAwE;IACxE,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,YAAY,CAAC,EAAE,MAAM,IAAI,CAAC;IAC1B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,CAAC,SAAS,UAAU,EAAE,CAAC,SAAS,OAAO,EACnE,MAAM,EAAE,YAAY,CAAC,CAAC,EAAE,CAAC,CAAC;;;;yBAoMc,CAAC;;IAqBvC,8EAA8E;;IAE9E,0EAA0E;;IAE1E,+EAA+E;;EAGlF"}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type HostMessage } from "@couch-kit/core";
|
|
2
|
+
/**
|
|
3
|
+
* Framework-free connection logic for the web game client.
|
|
4
|
+
*
|
|
5
|
+
* These helpers contain the pure, side-effect-free decision logic that the
|
|
6
|
+
* `useGameClient` hook relies on (URL derivation, reconnect/backoff scheduling,
|
|
7
|
+
* session-secret recovery, and host-message routing). They are kept separate
|
|
8
|
+
* from the React hook so the behavior can be unit-tested without a WebSocket or
|
|
9
|
+
* a DOM.
|
|
10
|
+
*/
|
|
11
|
+
/** localStorage key under which the session-recovery secret is persisted. */
|
|
12
|
+
export declare const SESSION_SECRET_KEY = "ck_secret";
|
|
13
|
+
/** Connection options relevant to deriving the WebSocket URL. */
|
|
14
|
+
export interface UrlResolutionConfig {
|
|
15
|
+
/** Full WebSocket URL. When set, it is used verbatim. */
|
|
16
|
+
url?: string;
|
|
17
|
+
/** WebSocket port override. Defaults to the page's HTTP port + offset. */
|
|
18
|
+
wsPort?: number;
|
|
19
|
+
}
|
|
20
|
+
/** The minimal shape of `window.location` needed to derive a WS URL. */
|
|
21
|
+
export interface LocationLike {
|
|
22
|
+
protocol: string;
|
|
23
|
+
hostname: string;
|
|
24
|
+
port: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the WebSocket URL to connect to.
|
|
28
|
+
*
|
|
29
|
+
* If `config.url` is provided it is returned as-is. Otherwise the URL is
|
|
30
|
+
* derived from the current page location using the convention
|
|
31
|
+
* `WS port = HTTP port + DEFAULT_WS_PORT_OFFSET` (HTTP 8080 -> WS 8082; port+1
|
|
32
|
+
* is skipped to avoid Metro's 8081). Returns `null` when no URL can be
|
|
33
|
+
* determined (no explicit URL and no location available).
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveWebSocketUrl(config: UrlResolutionConfig, location: LocationLike | null | undefined): string | null;
|
|
36
|
+
/**
|
|
37
|
+
* Compute the exponential-backoff delay (ms) for a reconnection attempt.
|
|
38
|
+
*
|
|
39
|
+
* `delay = min(baseDelay * 2^attempt, maxDelay)`, where `attempt` is the
|
|
40
|
+
* zero-based count of reconnects already made.
|
|
41
|
+
*/
|
|
42
|
+
export declare function computeBackoffDelay(attempt: number, baseDelay: number, maxDelay: number): number;
|
|
43
|
+
/** Inputs to the {@link shouldReconnect} decision. */
|
|
44
|
+
export interface ReconnectDecision {
|
|
45
|
+
/** Whether the disconnect was initiated locally (manual disconnect/unmount). */
|
|
46
|
+
intentionalClose: boolean;
|
|
47
|
+
/** The WebSocket close code from the `close` event. */
|
|
48
|
+
closeCode: number;
|
|
49
|
+
/** Number of reconnection attempts already made. */
|
|
50
|
+
attempts: number;
|
|
51
|
+
/** Maximum number of reconnection attempts permitted. */
|
|
52
|
+
maxRetries: number;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Decide whether the client should attempt an automatic reconnect.
|
|
56
|
+
*
|
|
57
|
+
* Returns `false` for intentional closes, for non-recoverable server close
|
|
58
|
+
* codes (1008 policy / 1011 internal error), or once the attempt budget is
|
|
59
|
+
* exhausted; otherwise `true`.
|
|
60
|
+
*/
|
|
61
|
+
export declare function shouldReconnect(decision: ReconnectDecision): boolean;
|
|
62
|
+
/** The minimal storage surface used for session-secret recovery. */
|
|
63
|
+
export type SecretStorage = Pick<Storage, "getItem" | "setItem">;
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the session-recovery secret.
|
|
66
|
+
*
|
|
67
|
+
* Reuses an existing secret from storage when present, otherwise generates a
|
|
68
|
+
* new one and persists it. When storage is unavailable or throws (e.g. Safari
|
|
69
|
+
* private browsing, restrictive WebViews), a fresh secret is generated without
|
|
70
|
+
* persistence so a JOIN can still proceed.
|
|
71
|
+
*/
|
|
72
|
+
export declare function resolveSessionSecret(storage: SecretStorage | null | undefined, generate?: () => string): string;
|
|
73
|
+
/**
|
|
74
|
+
* A side-effect descriptor produced by {@link interpretHostMessage}. The client
|
|
75
|
+
* hook executes these against React state so the routing logic itself stays
|
|
76
|
+
* pure and testable.
|
|
77
|
+
*/
|
|
78
|
+
export type HostMessageEffect<S> = {
|
|
79
|
+
kind: "setPlayerId";
|
|
80
|
+
playerId: string;
|
|
81
|
+
} | {
|
|
82
|
+
kind: "hydrate";
|
|
83
|
+
state: S;
|
|
84
|
+
} | {
|
|
85
|
+
kind: "pong";
|
|
86
|
+
payload: PongPayload;
|
|
87
|
+
};
|
|
88
|
+
/** Payload of a `PONG` host message. */
|
|
89
|
+
export type PongPayload = Extract<HostMessage, {
|
|
90
|
+
type: "PONG";
|
|
91
|
+
}>["payload"];
|
|
92
|
+
/**
|
|
93
|
+
* Translate a parsed host message into the ordered list of effects the client
|
|
94
|
+
* should apply. Unknown/irrelevant message types (e.g. `ERROR`) yield no
|
|
95
|
+
* effects.
|
|
96
|
+
*/
|
|
97
|
+
export declare function interpretHostMessage<S>(msg: HostMessage): HostMessageEffect<S>[];
|
|
98
|
+
//# sourceMappingURL=connection.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"connection.d.ts","sourceRoot":"","sources":["../src/connection.ts"],"names":[],"mappings":"AAAA,OAAO,EAKL,KAAK,WAAW,EACjB,MAAM,iBAAiB,CAAC;AAEzB;;;;;;;;GAQG;AAEH,6EAA6E;AAC7E,eAAO,MAAM,kBAAkB,cAAc,CAAC;AAQ9C,iEAAiE;AACjE,MAAM,WAAW,mBAAmB;IAClC,yDAAyD;IACzD,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,0EAA0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wEAAwE;AACxE,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;;;;;GAQG;AACH,wBAAgB,mBAAmB,CACjC,MAAM,EAAE,mBAAmB,EAC3B,QAAQ,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,GACxC,MAAM,GAAG,IAAI,CAUf;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,MAAM,EACf,SAAS,EAAE,MAAM,EACjB,QAAQ,EAAE,MAAM,GACf,MAAM,CAER;AAED,sDAAsD;AACtD,MAAM,WAAW,iBAAiB;IAChC,gFAAgF;IAChF,gBAAgB,EAAE,OAAO,CAAC;IAC1B,uDAAuD;IACvD,SAAS,EAAE,MAAM,CAAC;IAClB,oDAAoD;IACpD,QAAQ,EAAE,MAAM,CAAC;IACjB,yDAAyD;IACzD,UAAU,EAAE,MAAM,CAAC;CACpB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,QAAQ,EAAE,iBAAiB,GAAG,OAAO,CAIpE;AAED,oEAAoE;AACpE,MAAM,MAAM,aAAa,GAAG,IAAI,CAAC,OAAO,EAAE,SAAS,GAAG,SAAS,CAAC,CAAC;AAEjE;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,aAAa,GAAG,IAAI,GAAG,SAAS,EACzC,QAAQ,GAAE,MAAM,MAAmB,GAClC,MAAM,CAWR;AAED;;;;GAIG;AACH,MAAM,MAAM,iBAAiB,CAAC,CAAC,IAC3B;IAAE,IAAI,EAAE,aAAa,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,GACzC;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,KAAK,EAAE,CAAC,CAAA;CAAE,GAC7B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,WAAW,CAAA;CAAE,CAAC;AAE3C,wCAAwC;AACxC,MAAM,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,EAAE;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,CAAC,SAAS,CAAC,CAAC;AAE5E;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,CAAC,EACpC,GAAG,EAAE,WAAW,GACf,iBAAiB,CAAC,CAAC,CAAC,EAAE,CAmBxB"}
|
package/lib/index.d.ts
CHANGED
package/lib/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,UAAU,CAAC;AACzB,cAAc,cAAc,CAAC;AAC7B,cAAc,aAAa,CAAC;AAC5B,cAAc,UAAU,CAAC;AACzB,cAAc,eAAe,CAAC"}
|
package/package.json
CHANGED
package/src/client.ts
CHANGED
|
@@ -2,12 +2,9 @@ import { useState, useEffect, useRef, useCallback, useReducer } from "react";
|
|
|
2
2
|
import {
|
|
3
3
|
MessageTypes,
|
|
4
4
|
InternalActionTypes,
|
|
5
|
-
DEFAULT_WS_PORT_OFFSET,
|
|
6
|
-
DEFAULT_WS_PATH,
|
|
7
5
|
DEFAULT_MAX_RETRIES,
|
|
8
6
|
DEFAULT_BASE_DELAY,
|
|
9
7
|
DEFAULT_MAX_DELAY,
|
|
10
|
-
generateId,
|
|
11
8
|
createGameReducer,
|
|
12
9
|
type HostMessage,
|
|
13
10
|
type IGameState,
|
|
@@ -15,6 +12,13 @@ import {
|
|
|
15
12
|
type InternalAction,
|
|
16
13
|
} from "@couch-kit/core";
|
|
17
14
|
import { useServerTime } from "./time-sync";
|
|
15
|
+
import {
|
|
16
|
+
resolveWebSocketUrl,
|
|
17
|
+
computeBackoffDelay,
|
|
18
|
+
shouldReconnect,
|
|
19
|
+
resolveSessionSecret,
|
|
20
|
+
interpretHostMessage,
|
|
21
|
+
} from "./connection";
|
|
18
22
|
|
|
19
23
|
export interface ClientConfig<S extends IGameState, A extends IAction> {
|
|
20
24
|
url?: string; // Full WebSocket URL (overrides auto-detection)
|
|
@@ -100,15 +104,10 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
100
104
|
// so derive the WebSocket URL from window.location.
|
|
101
105
|
// Convention: WS port = HTTP port + 2 (e.g., HTTP 8080 -> WS 8082)
|
|
102
106
|
// Port + 1 is skipped to avoid conflicts with Metro bundler (which uses 8081)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const host = window.location.hostname;
|
|
108
|
-
const httpPort = parseInt(window.location.port, 10) || 80;
|
|
109
|
-
const wsPort = cfg.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
110
|
-
wsUrl = `${protocol}//${host}:${wsPort}${DEFAULT_WS_PATH}`;
|
|
111
|
-
}
|
|
107
|
+
const wsUrl = resolveWebSocketUrl(
|
|
108
|
+
{ url: cfg.url, wsPort: cfg.wsPort },
|
|
109
|
+
typeof window !== "undefined" ? window.location : null,
|
|
110
|
+
);
|
|
112
111
|
|
|
113
112
|
if (!wsUrl) return;
|
|
114
113
|
|
|
@@ -125,17 +124,9 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
125
124
|
currentCfg.onConnect?.();
|
|
126
125
|
|
|
127
126
|
// Session Recovery Logic -- use cryptographically random secrets
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (!secret) {
|
|
132
|
-
secret = generateId();
|
|
133
|
-
localStorage.setItem("ck_secret", secret);
|
|
134
|
-
}
|
|
135
|
-
} catch {
|
|
136
|
-
// localStorage unavailable (Safari private browsing, restrictive WebViews, etc.)
|
|
137
|
-
secret = generateId();
|
|
138
|
-
}
|
|
127
|
+
const secret = resolveSessionSecret(
|
|
128
|
+
typeof localStorage !== "undefined" ? localStorage : null,
|
|
129
|
+
);
|
|
139
130
|
|
|
140
131
|
// Join with secret
|
|
141
132
|
try {
|
|
@@ -156,41 +147,30 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
156
147
|
};
|
|
157
148
|
|
|
158
149
|
ws.onmessage = (event) => {
|
|
150
|
+
let msg: HostMessage;
|
|
159
151
|
try {
|
|
160
|
-
|
|
152
|
+
msg = JSON.parse(event.data) as HostMessage;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
console.error("Failed to parse message", e);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
161
157
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
dispatchLocal({
|
|
167
|
-
type: InternalActionTypes.HYDRATE,
|
|
168
|
-
payload: msg.payload.state as S,
|
|
169
|
-
} as InternalAction<S>);
|
|
158
|
+
for (const effect of interpretHostMessage<S>(msg)) {
|
|
159
|
+
switch (effect.kind) {
|
|
160
|
+
case "setPlayerId":
|
|
161
|
+
setPlayerId(effect.playerId);
|
|
170
162
|
break;
|
|
171
|
-
|
|
172
|
-
case MessageTypes.STATE_UPDATE:
|
|
163
|
+
case "hydrate":
|
|
173
164
|
// Full state replacement from the host's authoritative state.
|
|
174
165
|
dispatchLocal({
|
|
175
166
|
type: InternalActionTypes.HYDRATE,
|
|
176
|
-
payload:
|
|
167
|
+
payload: effect.state,
|
|
177
168
|
} as InternalAction<S>);
|
|
178
169
|
break;
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
handlePongRef.current(msg.payload);
|
|
182
|
-
break;
|
|
183
|
-
|
|
184
|
-
case MessageTypes.RECONNECTED:
|
|
185
|
-
setPlayerId(msg.payload.playerId);
|
|
186
|
-
dispatchLocal({
|
|
187
|
-
type: InternalActionTypes.HYDRATE,
|
|
188
|
-
payload: msg.payload.state as S,
|
|
189
|
-
} as InternalAction<S>);
|
|
170
|
+
case "pong":
|
|
171
|
+
handlePongRef.current(effect.payload);
|
|
190
172
|
break;
|
|
191
173
|
}
|
|
192
|
-
} catch (e) {
|
|
193
|
-
console.error("Failed to parse message", e);
|
|
194
174
|
}
|
|
195
175
|
};
|
|
196
176
|
|
|
@@ -199,25 +179,31 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
199
179
|
configRef.current.onDisconnect?.();
|
|
200
180
|
|
|
201
181
|
// Don't reconnect if the close was intentional or if the server
|
|
202
|
-
// sent a policy/unexpected error close code
|
|
203
|
-
if (
|
|
204
|
-
|
|
182
|
+
// sent a policy/unexpected error close code, or once retries are exhausted.
|
|
183
|
+
if (
|
|
184
|
+
!shouldReconnect({
|
|
185
|
+
intentionalClose: intentionalClose.current,
|
|
186
|
+
closeCode: event.code,
|
|
187
|
+
attempts: reconnectAttempts.current,
|
|
188
|
+
maxRetries,
|
|
189
|
+
})
|
|
190
|
+
)
|
|
191
|
+
return;
|
|
205
192
|
|
|
206
193
|
// Exponential backoff reconnection
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
194
|
+
const delay = computeBackoffDelay(
|
|
195
|
+
reconnectAttempts.current,
|
|
196
|
+
baseDelay,
|
|
197
|
+
maxDelay,
|
|
198
|
+
);
|
|
199
|
+
reconnectAttempts.current++;
|
|
213
200
|
|
|
214
|
-
|
|
215
|
-
|
|
201
|
+
if (configRef.current.debug)
|
|
202
|
+
console.log(`[GameClient] Reconnecting in ${delay}ms...`);
|
|
216
203
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
}
|
|
204
|
+
reconnectTimer.current = setTimeout(() => {
|
|
205
|
+
connect();
|
|
206
|
+
}, delay);
|
|
221
207
|
};
|
|
222
208
|
|
|
223
209
|
ws.onerror = (e) => {
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
MessageTypes,
|
|
3
|
+
DEFAULT_WS_PORT_OFFSET,
|
|
4
|
+
DEFAULT_WS_PATH,
|
|
5
|
+
generateId,
|
|
6
|
+
type HostMessage,
|
|
7
|
+
} from "@couch-kit/core";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Framework-free connection logic for the web game client.
|
|
11
|
+
*
|
|
12
|
+
* These helpers contain the pure, side-effect-free decision logic that the
|
|
13
|
+
* `useGameClient` hook relies on (URL derivation, reconnect/backoff scheduling,
|
|
14
|
+
* session-secret recovery, and host-message routing). They are kept separate
|
|
15
|
+
* from the React hook so the behavior can be unit-tested without a WebSocket or
|
|
16
|
+
* a DOM.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** localStorage key under which the session-recovery secret is persisted. */
|
|
20
|
+
export const SESSION_SECRET_KEY = "ck_secret";
|
|
21
|
+
|
|
22
|
+
/** The subset of `WebSocket` close codes that must NOT trigger a reconnect. */
|
|
23
|
+
const NON_RECOVERABLE_CLOSE_CODES = new Set<number>([
|
|
24
|
+
1008, // policy violation (e.g. INVALID_SECRET / FORBIDDEN_ACTION)
|
|
25
|
+
1011, // internal server error
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
/** Connection options relevant to deriving the WebSocket URL. */
|
|
29
|
+
export interface UrlResolutionConfig {
|
|
30
|
+
/** Full WebSocket URL. When set, it is used verbatim. */
|
|
31
|
+
url?: string;
|
|
32
|
+
/** WebSocket port override. Defaults to the page's HTTP port + offset. */
|
|
33
|
+
wsPort?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The minimal shape of `window.location` needed to derive a WS URL. */
|
|
37
|
+
export interface LocationLike {
|
|
38
|
+
protocol: string;
|
|
39
|
+
hostname: string;
|
|
40
|
+
port: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the WebSocket URL to connect to.
|
|
45
|
+
*
|
|
46
|
+
* If `config.url` is provided it is returned as-is. Otherwise the URL is
|
|
47
|
+
* derived from the current page location using the convention
|
|
48
|
+
* `WS port = HTTP port + DEFAULT_WS_PORT_OFFSET` (HTTP 8080 -> WS 8082; port+1
|
|
49
|
+
* is skipped to avoid Metro's 8081). Returns `null` when no URL can be
|
|
50
|
+
* determined (no explicit URL and no location available).
|
|
51
|
+
*/
|
|
52
|
+
export function resolveWebSocketUrl(
|
|
53
|
+
config: UrlResolutionConfig,
|
|
54
|
+
location: LocationLike | null | undefined,
|
|
55
|
+
): string | null {
|
|
56
|
+
if (config.url) return config.url;
|
|
57
|
+
if (!location) return null;
|
|
58
|
+
|
|
59
|
+
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
60
|
+
const host = location.hostname;
|
|
61
|
+
const httpPort = parseInt(location.port, 10) || 80;
|
|
62
|
+
const wsPort = config.wsPort || httpPort + DEFAULT_WS_PORT_OFFSET;
|
|
63
|
+
|
|
64
|
+
return `${protocol}//${host}:${wsPort}${DEFAULT_WS_PATH}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Compute the exponential-backoff delay (ms) for a reconnection attempt.
|
|
69
|
+
*
|
|
70
|
+
* `delay = min(baseDelay * 2^attempt, maxDelay)`, where `attempt` is the
|
|
71
|
+
* zero-based count of reconnects already made.
|
|
72
|
+
*/
|
|
73
|
+
export function computeBackoffDelay(
|
|
74
|
+
attempt: number,
|
|
75
|
+
baseDelay: number,
|
|
76
|
+
maxDelay: number,
|
|
77
|
+
): number {
|
|
78
|
+
return Math.min(baseDelay * Math.pow(2, attempt), maxDelay);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Inputs to the {@link shouldReconnect} decision. */
|
|
82
|
+
export interface ReconnectDecision {
|
|
83
|
+
/** Whether the disconnect was initiated locally (manual disconnect/unmount). */
|
|
84
|
+
intentionalClose: boolean;
|
|
85
|
+
/** The WebSocket close code from the `close` event. */
|
|
86
|
+
closeCode: number;
|
|
87
|
+
/** Number of reconnection attempts already made. */
|
|
88
|
+
attempts: number;
|
|
89
|
+
/** Maximum number of reconnection attempts permitted. */
|
|
90
|
+
maxRetries: number;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decide whether the client should attempt an automatic reconnect.
|
|
95
|
+
*
|
|
96
|
+
* Returns `false` for intentional closes, for non-recoverable server close
|
|
97
|
+
* codes (1008 policy / 1011 internal error), or once the attempt budget is
|
|
98
|
+
* exhausted; otherwise `true`.
|
|
99
|
+
*/
|
|
100
|
+
export function shouldReconnect(decision: ReconnectDecision): boolean {
|
|
101
|
+
if (decision.intentionalClose) return false;
|
|
102
|
+
if (NON_RECOVERABLE_CLOSE_CODES.has(decision.closeCode)) return false;
|
|
103
|
+
return decision.attempts < decision.maxRetries;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The minimal storage surface used for session-secret recovery. */
|
|
107
|
+
export type SecretStorage = Pick<Storage, "getItem" | "setItem">;
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Resolve the session-recovery secret.
|
|
111
|
+
*
|
|
112
|
+
* Reuses an existing secret from storage when present, otherwise generates a
|
|
113
|
+
* new one and persists it. When storage is unavailable or throws (e.g. Safari
|
|
114
|
+
* private browsing, restrictive WebViews), a fresh secret is generated without
|
|
115
|
+
* persistence so a JOIN can still proceed.
|
|
116
|
+
*/
|
|
117
|
+
export function resolveSessionSecret(
|
|
118
|
+
storage: SecretStorage | null | undefined,
|
|
119
|
+
generate: () => string = generateId,
|
|
120
|
+
): string {
|
|
121
|
+
try {
|
|
122
|
+
if (!storage) return generate();
|
|
123
|
+
const stored = storage.getItem(SESSION_SECRET_KEY);
|
|
124
|
+
if (stored) return stored;
|
|
125
|
+
const secret = generate();
|
|
126
|
+
storage.setItem(SESSION_SECRET_KEY, secret);
|
|
127
|
+
return secret;
|
|
128
|
+
} catch {
|
|
129
|
+
return generate();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A side-effect descriptor produced by {@link interpretHostMessage}. The client
|
|
135
|
+
* hook executes these against React state so the routing logic itself stays
|
|
136
|
+
* pure and testable.
|
|
137
|
+
*/
|
|
138
|
+
export type HostMessageEffect<S> =
|
|
139
|
+
| { kind: "setPlayerId"; playerId: string }
|
|
140
|
+
| { kind: "hydrate"; state: S }
|
|
141
|
+
| { kind: "pong"; payload: PongPayload };
|
|
142
|
+
|
|
143
|
+
/** Payload of a `PONG` host message. */
|
|
144
|
+
export type PongPayload = Extract<HostMessage, { type: "PONG" }>["payload"];
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Translate a parsed host message into the ordered list of effects the client
|
|
148
|
+
* should apply. Unknown/irrelevant message types (e.g. `ERROR`) yield no
|
|
149
|
+
* effects.
|
|
150
|
+
*/
|
|
151
|
+
export function interpretHostMessage<S>(
|
|
152
|
+
msg: HostMessage,
|
|
153
|
+
): HostMessageEffect<S>[] {
|
|
154
|
+
switch (msg.type) {
|
|
155
|
+
case MessageTypes.WELCOME:
|
|
156
|
+
return [
|
|
157
|
+
{ kind: "setPlayerId", playerId: msg.payload.playerId },
|
|
158
|
+
{ kind: "hydrate", state: msg.payload.state as S },
|
|
159
|
+
];
|
|
160
|
+
case MessageTypes.STATE_UPDATE:
|
|
161
|
+
return [{ kind: "hydrate", state: msg.payload.newState as S }];
|
|
162
|
+
case MessageTypes.PONG:
|
|
163
|
+
return [{ kind: "pong", payload: msg.payload }];
|
|
164
|
+
case MessageTypes.RECONNECTED:
|
|
165
|
+
return [
|
|
166
|
+
{ kind: "setPlayerId", playerId: msg.payload.playerId },
|
|
167
|
+
{ kind: "hydrate", state: msg.payload.state as S },
|
|
168
|
+
];
|
|
169
|
+
default:
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|