@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 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 MessageTypes2,
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
- let wsUrl = cfg.url;
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
- let secret = null;
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: MessageTypes2.JOIN,
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
- const msg = JSON.parse(event.data);
143
- switch (msg.type) {
144
- case MessageTypes2.WELCOME:
145
- setPlayerId(msg.payload.playerId);
146
- dispatchLocal({
147
- type: InternalActionTypes.HYDRATE,
148
- payload: msg.payload.state
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 MessageTypes2.STATE_UPDATE:
202
+ case "hydrate":
152
203
  dispatchLocal({
153
204
  type: InternalActionTypes.HYDRATE,
154
- payload: msg.payload.newState
205
+ payload: effect.state
155
206
  });
156
207
  break;
157
- case MessageTypes2.PONG:
158
- handlePongRef.current(msg.payload);
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 (intentionalClose.current)
176
- return;
177
- if (event.code === 1008 || event.code === 1011)
217
+ if (!shouldReconnect({
218
+ intentionalClose: intentionalClose.current,
219
+ closeCode: event.code,
220
+ attempts: reconnectAttempts.current,
221
+ maxRetries
222
+ }))
178
223
  return;
179
- if (reconnectAttempts.current < maxRetries) {
180
- const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts.current), maxDelay);
181
- reconnectAttempts.current++;
182
- if (configRef.current.debug)
183
- console.log(`[GameClient] Reconnecting in ${delay}ms...`);
184
- reconnectTimer.current = setTimeout(() => {
185
- connect();
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: MessageTypes2.ACTION,
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 MessageTypes3 } from "@couch-kit/core";
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: MessageTypes3.ASSETS_LOADED,
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: MessageTypes3.ASSETS_LOADED,
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
- calculateTimeSync
424
+ shouldReconnect,
425
+ resolveWebSocketUrl,
426
+ resolveSessionSecret,
427
+ interpretHostMessage,
428
+ computeBackoffDelay,
429
+ calculateTimeSync,
430
+ SESSION_SECRET_KEY
382
431
  };
@@ -1 +1 @@
1
- {"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../src/client.ts"],"names":[],"mappings":"AACA,OAAO,EAWL,KAAK,UAAU,EACf,KAAK,OAAO,EAEb,MAAM,iBAAiB,CAAC;AAGzB,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;;;;yBAsNc,CAAC;;IAqBvC,8EAA8E;;IAE9E,0EAA0E;;IAE1E,+EAA+E;;EAGlF"}
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
@@ -1,4 +1,5 @@
1
1
  export * from "./client";
2
+ export * from "./connection";
2
3
  export * from "./time-sync";
3
4
  export * from "./assets";
4
5
  export * from "./debug-panel";
@@ -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.5",
3
+ "version": "0.8.7",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "provenance": true
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
- let wsUrl = cfg.url;
104
-
105
- if (!wsUrl && typeof window !== "undefined") {
106
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
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
- let secret: string | null = null;
129
- try {
130
- secret = localStorage.getItem("ck_secret");
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
- const msg = JSON.parse(event.data) as HostMessage;
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
- switch (msg.type) {
163
- case MessageTypes.WELCOME:
164
- setPlayerId(msg.payload.playerId);
165
- // Hydrate state from server (Single Source of Truth)
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: msg.payload.newState as S,
167
+ payload: effect.state,
177
168
  } as InternalAction<S>);
178
169
  break;
179
-
180
- case MessageTypes.PONG:
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 (intentionalClose.current) return;
204
- if (event.code === 1008 || event.code === 1011) return;
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
- if (reconnectAttempts.current < maxRetries) {
208
- const delay = Math.min(
209
- baseDelay * Math.pow(2, reconnectAttempts.current),
210
- maxDelay,
211
- );
212
- reconnectAttempts.current++;
194
+ const delay = computeBackoffDelay(
195
+ reconnectAttempts.current,
196
+ baseDelay,
197
+ maxDelay,
198
+ );
199
+ reconnectAttempts.current++;
213
200
 
214
- if (configRef.current.debug)
215
- console.log(`[GameClient] Reconnecting in ${delay}ms...`);
201
+ if (configRef.current.debug)
202
+ console.log(`[GameClient] Reconnecting in ${delay}ms...`);
216
203
 
217
- reconnectTimer.current = setTimeout(() => {
218
- connect();
219
- }, delay);
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
+ }
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from "./client";
2
+ export * from "./connection";
2
3
  export * from "./time-sync";
3
4
  export * from "./assets";
4
5
  export * from "./debug-panel";