@couch-kit/client 0.8.6 → 0.8.8
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 +16 -0
- package/README.md +15 -1
- package/dist/index.js +110 -63
- 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 +3 -3
- package/src/client.ts +49 -65
- package/src/connection.ts +172 -0
- package/src/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# @couch-kit/client
|
|
2
2
|
|
|
3
|
+
## 0.8.8
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [[`0064a42`](https://github.com/faluciano/react-native-couch-kit/commit/0064a42e0593d2ed2734c27926c5df686dcf3b5f)]:
|
|
8
|
+
- @couch-kit/core@0.9.2
|
|
9
|
+
|
|
10
|
+
## 0.8.7
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- [#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
|
|
15
|
+
backoff, session-secret recovery, and host-message interpretation) into a
|
|
16
|
+
framework-free `connection` module with full unit test coverage. No behavior
|
|
17
|
+
change; these pure helpers are now exported for reuse and testing.
|
|
18
|
+
|
|
3
19
|
## 0.8.6
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @couch-kit/client
|
|
2
2
|
|
|
3
|
-
The client-side
|
|
3
|
+
The client-side React hooks for the web controller.
|
|
4
4
|
|
|
5
5
|
## Features
|
|
6
6
|
|
|
@@ -159,6 +159,20 @@ function App() {
|
|
|
159
159
|
}
|
|
160
160
|
```
|
|
161
161
|
|
|
162
|
+
### 4. Debug Panel
|
|
163
|
+
|
|
164
|
+
Captures connection status, state history, and action log for use with the `@couch-kit/devtools` overlay (or your own debug UI).
|
|
165
|
+
|
|
166
|
+
```tsx
|
|
167
|
+
import { useGameClient, useDebugPanel } from "@couch-kit/client";
|
|
168
|
+
|
|
169
|
+
const client = useGameClient({ reducer, initialState });
|
|
170
|
+
const debugData = useDebugPanel(client);
|
|
171
|
+
// Pass `debugData` to <DebugOverlay data={debugData} /> from @couch-kit/devtools
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Returns a `DebugPanelData` object with connection status, state snapshots, and action entries.
|
|
175
|
+
|
|
162
176
|
## Notes / Limitations
|
|
163
177
|
|
|
164
178
|
- `usePreload()` preloads images via `Image()` and uses `fetch()` for other URLs; it does not currently send the protocol `ASSETS_LOADED` message. Failed assets are available via `failedAssets`.
|
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,21 +171,10 @@ function useGameClient(config) {
|
|
|
113
171
|
setStatus("connected");
|
|
114
172
|
reconnectAttempts.current = 0;
|
|
115
173
|
currentCfg.onConnect?.();
|
|
116
|
-
|
|
117
|
-
try {
|
|
118
|
-
const stored = localStorage.getItem("ck_secret");
|
|
119
|
-
if (stored) {
|
|
120
|
-
secret = stored;
|
|
121
|
-
} else {
|
|
122
|
-
secret = generateId2();
|
|
123
|
-
localStorage.setItem("ck_secret", secret);
|
|
124
|
-
}
|
|
125
|
-
} catch {
|
|
126
|
-
secret = generateId2();
|
|
127
|
-
}
|
|
174
|
+
const secret = resolveSessionSecret(typeof localStorage !== "undefined" ? localStorage : null);
|
|
128
175
|
try {
|
|
129
176
|
ws.send(JSON.stringify({
|
|
130
|
-
type:
|
|
177
|
+
type: MessageTypes3.JOIN,
|
|
131
178
|
payload: {
|
|
132
179
|
name: currentCfg.name || "Player",
|
|
133
180
|
avatar: currentCfg.avatar || "\uD83D\uDE00",
|
|
@@ -140,53 +187,47 @@ function useGameClient(config) {
|
|
|
140
187
|
}
|
|
141
188
|
};
|
|
142
189
|
ws.onmessage = (event) => {
|
|
190
|
+
let msg;
|
|
143
191
|
try {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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);
|
|
152
201
|
break;
|
|
153
|
-
case
|
|
202
|
+
case "hydrate":
|
|
154
203
|
dispatchLocal({
|
|
155
204
|
type: InternalActionTypes.HYDRATE,
|
|
156
|
-
payload:
|
|
205
|
+
payload: effect.state
|
|
157
206
|
});
|
|
158
207
|
break;
|
|
159
|
-
case
|
|
160
|
-
handlePongRef.current(
|
|
161
|
-
break;
|
|
162
|
-
case MessageTypes2.RECONNECTED:
|
|
163
|
-
setPlayerId(msg.payload.playerId);
|
|
164
|
-
dispatchLocal({
|
|
165
|
-
type: InternalActionTypes.HYDRATE,
|
|
166
|
-
payload: msg.payload.state
|
|
167
|
-
});
|
|
208
|
+
case "pong":
|
|
209
|
+
handlePongRef.current(effect.payload);
|
|
168
210
|
break;
|
|
169
211
|
}
|
|
170
|
-
} catch (e) {
|
|
171
|
-
console.error("Failed to parse message", e);
|
|
172
212
|
}
|
|
173
213
|
};
|
|
174
214
|
ws.onclose = (event) => {
|
|
175
215
|
setStatus("disconnected");
|
|
176
216
|
configRef.current.onDisconnect?.();
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
|
|
217
|
+
if (!shouldReconnect({
|
|
218
|
+
intentionalClose: intentionalClose.current,
|
|
219
|
+
closeCode: event.code,
|
|
220
|
+
attempts: reconnectAttempts.current,
|
|
221
|
+
maxRetries
|
|
222
|
+
}))
|
|
180
223
|
return;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}, delay);
|
|
189
|
-
}
|
|
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);
|
|
190
231
|
};
|
|
191
232
|
ws.onerror = (e) => {
|
|
192
233
|
if (configRef.current.debug)
|
|
@@ -225,7 +266,7 @@ function useGameClient(config) {
|
|
|
225
266
|
dispatchLocal(action);
|
|
226
267
|
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
|
227
268
|
socketRef.current.send(JSON.stringify({
|
|
228
|
-
type:
|
|
269
|
+
type: MessageTypes3.ACTION,
|
|
229
270
|
payload: action
|
|
230
271
|
}));
|
|
231
272
|
}
|
|
@@ -243,7 +284,7 @@ function useGameClient(config) {
|
|
|
243
284
|
}
|
|
244
285
|
// src/assets.ts
|
|
245
286
|
import { useState as useState3, useEffect as useEffect3, useRef as useRef3 } from "react";
|
|
246
|
-
import { MessageTypes as
|
|
287
|
+
import { MessageTypes as MessageTypes4 } from "@couch-kit/core";
|
|
247
288
|
function arraysEqual(a, b) {
|
|
248
289
|
if (a.length !== b.length)
|
|
249
290
|
return false;
|
|
@@ -270,7 +311,7 @@ function usePreload(assets, sendMessage) {
|
|
|
270
311
|
setProgress(100);
|
|
271
312
|
setFailedAssets([]);
|
|
272
313
|
sendMessageRef.current?.({
|
|
273
|
-
type:
|
|
314
|
+
type: MessageTypes4.ASSETS_LOADED,
|
|
274
315
|
payload: true
|
|
275
316
|
});
|
|
276
317
|
return;
|
|
@@ -288,7 +329,7 @@ function usePreload(assets, sendMessage) {
|
|
|
288
329
|
setFailedAssets([...failed]);
|
|
289
330
|
setLoaded(true);
|
|
290
331
|
sendMessageRef.current?.({
|
|
291
|
-
type:
|
|
332
|
+
type: MessageTypes4.ASSETS_LOADED,
|
|
292
333
|
payload: true
|
|
293
334
|
});
|
|
294
335
|
}
|
|
@@ -380,5 +421,11 @@ export {
|
|
|
380
421
|
usePreload,
|
|
381
422
|
useGameClient,
|
|
382
423
|
useDebugPanel,
|
|
383
|
-
|
|
424
|
+
shouldReconnect,
|
|
425
|
+
resolveWebSocketUrl,
|
|
426
|
+
resolveSessionSecret,
|
|
427
|
+
interpretHostMessage,
|
|
428
|
+
computeBackoffDelay,
|
|
429
|
+
calculateTimeSync,
|
|
430
|
+
SESSION_SECRET_KEY
|
|
384
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@couch-kit/client",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.8",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"provenance": true
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
"clean": "rm -rf dist lib"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@couch-kit/core": "0.9.
|
|
53
|
+
"@couch-kit/core": "0.9.2"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"react": "^18.2.0",
|
|
57
|
-
"typescript": "^
|
|
57
|
+
"typescript": "^6.0.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"react": ">=18.0.0"
|
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,19 +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 (stored) {
|
|
132
|
-
secret = stored;
|
|
133
|
-
} else {
|
|
134
|
-
secret = generateId();
|
|
135
|
-
localStorage.setItem("ck_secret", secret);
|
|
136
|
-
}
|
|
137
|
-
} catch {
|
|
138
|
-
// localStorage unavailable (Safari private browsing, restrictive WebViews, etc.)
|
|
139
|
-
secret = generateId();
|
|
140
|
-
}
|
|
127
|
+
const secret = resolveSessionSecret(
|
|
128
|
+
typeof localStorage !== "undefined" ? localStorage : null,
|
|
129
|
+
);
|
|
141
130
|
|
|
142
131
|
// Join with secret
|
|
143
132
|
try {
|
|
@@ -158,41 +147,30 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
158
147
|
};
|
|
159
148
|
|
|
160
149
|
ws.onmessage = (event) => {
|
|
150
|
+
let msg: HostMessage;
|
|
161
151
|
try {
|
|
162
|
-
|
|
152
|
+
msg = JSON.parse(event.data) as HostMessage;
|
|
153
|
+
} catch (e) {
|
|
154
|
+
console.error("Failed to parse message", e);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
163
157
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
dispatchLocal({
|
|
169
|
-
type: InternalActionTypes.HYDRATE,
|
|
170
|
-
payload: msg.payload.state as S,
|
|
171
|
-
} as InternalAction<S>);
|
|
158
|
+
for (const effect of interpretHostMessage<S>(msg)) {
|
|
159
|
+
switch (effect.kind) {
|
|
160
|
+
case "setPlayerId":
|
|
161
|
+
setPlayerId(effect.playerId);
|
|
172
162
|
break;
|
|
173
|
-
|
|
174
|
-
case MessageTypes.STATE_UPDATE:
|
|
163
|
+
case "hydrate":
|
|
175
164
|
// Full state replacement from the host's authoritative state.
|
|
176
165
|
dispatchLocal({
|
|
177
166
|
type: InternalActionTypes.HYDRATE,
|
|
178
|
-
payload:
|
|
167
|
+
payload: effect.state,
|
|
179
168
|
} as InternalAction<S>);
|
|
180
169
|
break;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
handlePongRef.current(msg.payload);
|
|
184
|
-
break;
|
|
185
|
-
|
|
186
|
-
case MessageTypes.RECONNECTED:
|
|
187
|
-
setPlayerId(msg.payload.playerId);
|
|
188
|
-
dispatchLocal({
|
|
189
|
-
type: InternalActionTypes.HYDRATE,
|
|
190
|
-
payload: msg.payload.state as S,
|
|
191
|
-
} as InternalAction<S>);
|
|
170
|
+
case "pong":
|
|
171
|
+
handlePongRef.current(effect.payload);
|
|
192
172
|
break;
|
|
193
173
|
}
|
|
194
|
-
} catch (e) {
|
|
195
|
-
console.error("Failed to parse message", e);
|
|
196
174
|
}
|
|
197
175
|
};
|
|
198
176
|
|
|
@@ -201,25 +179,31 @@ export function useGameClient<S extends IGameState, A extends IAction>(
|
|
|
201
179
|
configRef.current.onDisconnect?.();
|
|
202
180
|
|
|
203
181
|
// Don't reconnect if the close was intentional or if the server
|
|
204
|
-
// sent a policy/unexpected error close code
|
|
205
|
-
if (
|
|
206
|
-
|
|
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;
|
|
207
192
|
|
|
208
193
|
// Exponential backoff reconnection
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
194
|
+
const delay = computeBackoffDelay(
|
|
195
|
+
reconnectAttempts.current,
|
|
196
|
+
baseDelay,
|
|
197
|
+
maxDelay,
|
|
198
|
+
);
|
|
199
|
+
reconnectAttempts.current++;
|
|
215
200
|
|
|
216
|
-
|
|
217
|
-
|
|
201
|
+
if (configRef.current.debug)
|
|
202
|
+
console.log(`[GameClient] Reconnecting in ${delay}ms...`);
|
|
218
203
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
}
|
|
204
|
+
reconnectTimer.current = setTimeout(() => {
|
|
205
|
+
connect();
|
|
206
|
+
}, delay);
|
|
223
207
|
};
|
|
224
208
|
|
|
225
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
|
+
}
|