@couch-kit/client 0.8.6 → 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,14 @@
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
+
3
12
  ## 0.8.6
4
13
 
5
14
  ### 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,21 +171,10 @@ function useGameClient(config) {
113
171
  setStatus("connected");
114
172
  reconnectAttempts.current = 0;
115
173
  currentCfg.onConnect?.();
116
- let secret;
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: MessageTypes2.JOIN,
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
- const msg = JSON.parse(event.data);
145
- switch (msg.type) {
146
- case MessageTypes2.WELCOME:
147
- setPlayerId(msg.payload.playerId);
148
- dispatchLocal({
149
- type: InternalActionTypes.HYDRATE,
150
- payload: msg.payload.state
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 MessageTypes2.STATE_UPDATE:
202
+ case "hydrate":
154
203
  dispatchLocal({
155
204
  type: InternalActionTypes.HYDRATE,
156
- payload: msg.payload.newState
205
+ payload: effect.state
157
206
  });
158
207
  break;
159
- case MessageTypes2.PONG:
160
- handlePongRef.current(msg.payload);
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 (intentionalClose.current)
178
- return;
179
- 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
+ }))
180
223
  return;
181
- if (reconnectAttempts.current < maxRetries) {
182
- const delay = Math.min(baseDelay * Math.pow(2, reconnectAttempts.current), maxDelay);
183
- reconnectAttempts.current++;
184
- if (configRef.current.debug)
185
- console.log(`[GameClient] Reconnecting in ${delay}ms...`);
186
- reconnectTimer.current = setTimeout(() => {
187
- connect();
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: MessageTypes2.ACTION,
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 MessageTypes3 } from "@couch-kit/core";
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: MessageTypes3.ASSETS_LOADED,
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: MessageTypes3.ASSETS_LOADED,
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
- calculateTimeSync
424
+ shouldReconnect,
425
+ resolveWebSocketUrl,
426
+ resolveSessionSecret,
427
+ interpretHostMessage,
428
+ computeBackoffDelay,
429
+ calculateTimeSync,
430
+ SESSION_SECRET_KEY
384
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;;;;yBAwNc,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.6",
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,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
- let secret: string;
129
- try {
130
- const stored = localStorage.getItem("ck_secret");
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
- 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
+ }
163
157
 
164
- switch (msg.type) {
165
- case MessageTypes.WELCOME:
166
- setPlayerId(msg.payload.playerId);
167
- // Hydrate state from server (Single Source of Truth)
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: msg.payload.newState as S,
167
+ payload: effect.state,
179
168
  } as InternalAction<S>);
180
169
  break;
181
-
182
- case MessageTypes.PONG:
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 (intentionalClose.current) return;
206
- 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;
207
192
 
208
193
  // Exponential backoff reconnection
209
- if (reconnectAttempts.current < maxRetries) {
210
- const delay = Math.min(
211
- baseDelay * Math.pow(2, reconnectAttempts.current),
212
- maxDelay,
213
- );
214
- reconnectAttempts.current++;
194
+ const delay = computeBackoffDelay(
195
+ reconnectAttempts.current,
196
+ baseDelay,
197
+ maxDelay,
198
+ );
199
+ reconnectAttempts.current++;
215
200
 
216
- if (configRef.current.debug)
217
- console.log(`[GameClient] Reconnecting in ${delay}ms...`);
201
+ if (configRef.current.debug)
202
+ console.log(`[GameClient] Reconnecting in ${delay}ms...`);
218
203
 
219
- reconnectTimer.current = setTimeout(() => {
220
- connect();
221
- }, delay);
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
+ }
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";